omp-statusliney 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,169 @@
1
- # Temporary Holding Version
1
+ # omp-statusliney
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A fully customizable statusline for [Oh My Pi](https://omp.sh) (`omp`). It replaces the built-in statusline completely, including the startup prepaint, and draws a multi-line bar like ccstatusline. It keeps all 27 built-in segments and adds 25 new widgets.
4
+
5
+ ![A two-line statusliney bar: model, path, git branch and diff stats on line one; context bar, token counts, cache hit rate and cost on line two](docs/statusline.png)
6
+
7
+ ## Requirements
8
+
9
+ - omp `>=18.12.2 <19`
10
+ - Bun `>=1.3.14` (omp already runs on Bun)
11
+
12
+ ## Installation
13
+
14
+ omp-statusliney is an omp plugin. It does not work in upstream pi, because it hooks into omp's own statusline.
15
+
16
+ ### From npm
17
+
18
+ ```sh
19
+ omp plugin install omp-statusliney
20
+ ```
21
+
22
+ ### From the omp marketplace
23
+
24
+ This repository is also an omp marketplace catalog (`.omp-plugin/marketplace.json`):
25
+
26
+ ```sh
27
+ omp plugin marketplace add ignissak/omp-statusliney
28
+ omp plugin install omp-statusliney@ignissak
29
+ ```
30
+
31
+ Marketplace installs track the `main` branch. Use `omp plugin upgrade omp-statusliney@ignissak` to update.
32
+
33
+ ### From a local checkout
34
+
35
+ ```sh
36
+ git clone https://github.com/ignissak/omp-statusliney.git
37
+ cd omp-statusliney
38
+ bun install
39
+ omp plugin link "$PWD"
40
+ ```
41
+
42
+ `omp plugin link` points omp at the checkout, so later source changes take effect the next time omp starts.
43
+
44
+ Install omp-statusliney only one way at a time. omp refuses a second copy of a package it already has.
45
+
46
+ ### Check the install
47
+
48
+ ```sh
49
+ omp plugin doctor # should list "plugin:omp-statusliney"
50
+ omp # the new bar replaces the built-in one
51
+ ```
52
+
53
+ On first launch the plugin writes `~/.omp/agent/statusliney.json` with a layout built from your current built-in statusline segments. On the band and box composers it looks the same as the built-in bar. On shapes where the bar sits below the prompt, it uses your configured separator instead of the built-in plain dots.
54
+
55
+ ### Uninstall
56
+
57
+ ```sh
58
+ omp plugin uninstall omp-statusliney
59
+ ```
60
+
61
+ The built-in statusline returns on the next launch. The config file stays in place; delete `~/.omp/agent/statusliney.json` to remove it.
62
+
63
+ ## Configuration
64
+
65
+ Run `/statusliney-settings` in the TUI. Each change applies to the live bar right away and is saved automatically.
66
+
67
+ - **Lines & widgets**: 1–6 lines. Add (`a`), insert (`i`), delete (`d` twice), move (`[` `]`) and clone (`c`) widgets. Set each widget's color (theme tokens only), bold, label and options.
68
+ - **Appearance**: placement (`auto` puts the bar in the composer frame when it has one, otherwise below the prompt; or force `top`/`bottom`), separator (`inherit` follows `statusLine.separator`), background, hook status rows, refresh intervals.
69
+ - **Presets**: the built-in presets, `Two-line dev` and `Focused`. Moving through the list previews each preset on the live bar. Enter applies it; Esc restores your layout.
70
+ - **Reset to built-in layout**: copy your current built-in statusline settings again.
71
+
72
+ You can also edit the JSON by hand; the plugin picks up external edits while omp is running. Set `OMP_STATUSLINEY_CONFIG` to use a different file.
73
+
74
+ The bar follows omp's theme and symbol preset (all glyphs and colors come from the theme). It also honors your `statusLine.*` preferences (segment options, transparent, session accent, compact thinking level, context line, hook status) and the composer shape.
75
+
76
+ ### Widgets
77
+
78
+ | Category | Widgets |
79
+ |---|---|
80
+ | omp built-in | every built-in segment (`pi`, `model`, `path`, `git`, `pr`, `cost`, `context_pct`, `session_name`, `usage`, `vim`, …) |
81
+ | Git | `git_ahead_behind`, `git_sha`, `git_changes`, `git_stash`, `git_clean` |
82
+ | Context | `context_gauge` (the built-in context rule), `context_bar`, `context_remaining`, `quota_bar` |
83
+ | Session | `cost_rate`, `turns`, `compactions`, `session_clock`, `tool` |
84
+ | Environment | `jobs`, `thinking`, `provider`, `omp_version`, `terminal_width`, `memory` |
85
+ | Custom | `text`, `custom_command` (first output line, cached), `link` (OSC 8) |
86
+ | Layout | `separator`, `flex` |
87
+
88
+ ## Local development
89
+
90
+ ### Setup
91
+
92
+ ```sh
93
+ bun install
94
+ ```
95
+
96
+ This installs the `@oh-my-pi/*` packages as dev dependencies for types and tests. At runtime omp maps every `@oh-my-pi/*` import to its own bundled modules, so the plugin always runs against the live host.
97
+
98
+ The plugin is TypeScript that Bun runs directly; there is no build step. The entry point is `src/index.ts`, declared under `omp.extensions` in `package.json`.
99
+
100
+ ### Run omp with your working copy
101
+
102
+ Load the checkout as an extension without installing it, and use a scratch config so your real one is untouched:
103
+
104
+ ```sh
105
+ export OMP_STATUSLINEY_CONFIG=/tmp/sly/statusliney.json
106
+ omp --no-session -e "$PWD"
107
+ ```
108
+
109
+ - `--no-session` skips writing a session file.
110
+ - Add `--no-extensions` to load only this plugin, without your other plugins.
111
+ - Restart omp after each source change.
112
+ - Delete the scratch config to start again from the mirrored built-in layout.
113
+
114
+ ### Tests and type checks
115
+
116
+ ```sh
117
+ bun test # config normalization, store round-trip, layout engine, presets
118
+ bun run typecheck # tsc --noEmit
119
+ ```
120
+
121
+ ### Project layout
122
+
123
+ | Path | Purpose |
124
+ |---|---|
125
+ | `src/index.ts` | Plugin entry: events, ticker, `/statusliney-settings` command |
126
+ | `src/takeover.ts` | Patches omp's `StatusLineComponent`, captures the live segment context, scrubs the startup cache |
127
+ | `src/frame.ts` | Placement, surfaces, hook rows, per-frame rendering |
128
+ | `src/layout.ts` | Pure line layout: groups, separators, flex fill, overflow |
129
+ | `src/config.ts`, `src/store.ts` | Config schema, validation, atomic save, file watch |
130
+ | `src/presets.ts` | Built-in mirror and named presets |
131
+ | `src/widgets/` | Widget contract, registry and widget implementations |
132
+ | `src/ui/` | Settings overlay and its views |
133
+ | `test/` | Bun tests |
134
+
135
+ ### Debugging
136
+
137
+ - Plugin messages go to omp's log file, `~/.omp/logs/omp.<DATE>.<PID>.log`, which records every level by default. Filter with `grep statusliney`. Set `OMP_LOG_LEVEL` (`error|warn|info|debug`) to limit what is written.
138
+ - The plugin patches omp internals. If an omp release removes a member it patches, the plugin leaves the built-in bar in place and shows a `statusliney: unsupported omp internals` warning. If rendering keeps failing at runtime (three frames in a row), it restores the built-in bar and warns. Fix the patch targets in `src/takeover.ts` (`canTakeOver`).
139
+ - On native TSP terminals the plugin passes your real settings through, so the terminal's own statusline is unchanged.
140
+
141
+ ## Releasing
142
+
143
+ Releases are automated by `.github/workflows/release.yml`. To release, bump `version` in `package.json` and push it to `main`:
144
+
145
+ ```sh
146
+ npm version minor --no-git-tag-version # or patch / major, or edit package.json by hand
147
+ git commit -am "chore(release): 1.1.0"
148
+ git push
149
+ ```
150
+
151
+ The workflow then:
152
+
153
+ 1. Runs the tests and the type check.
154
+ 2. Publishes the version to npm with provenance, unless it is already there.
155
+ 3. Creates the tag `v<version>` and a GitHub release. The release notes come from the commits since the previous tag, grouped by their Conventional Commit type (breaking changes, features, fixes, other). Version-bump commits are left out.
156
+
157
+ Each step skips itself if it is already done, so a failed run can simply be re-run. Preview the notes locally with `bun scripts/release-notes.ts v1.1.0`.
158
+
159
+ The npm package carries the `pi-package` keyword and a `pi.image` preview, so it also appears in the [pi package gallery](https://pi.dev/packages) once it is on npm.
160
+
161
+ ### One-time setup
162
+
163
+ npm only allows trusted publishing for a package that already exists, so the first version is published by hand:
164
+
165
+ 1. `npm login`, then `npm publish --access public` from a clean checkout of `main`.
166
+ 2. On npmjs.com, open the package's **Settings → Trusted publishing** and add GitHub Actions with repository `ignissak/omp-statusliney` and workflow `release.yml`.
167
+ 3. Push `main`, or run the **Release** workflow by hand. It sees the version already on npm, so it only creates the tag and the release.
168
+
169
+ After that, no npm token is needed: the workflow authenticates through GitHub's OIDC token.
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "omp-statusliney",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Fully customizable statusline for Oh My Pi (omp)",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "omp",
9
+ "oh-my-pi",
10
+ "omp-plugin",
11
+ "statusline",
12
+ "pi-package"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/ignissak/omp-statusliney.git"
17
+ },
18
+ "homepage": "https://github.com/ignissak/omp-statusliney#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/ignissak/omp-statusliney/issues"
21
+ },
22
+ "omp": {
23
+ "description": "Replaces the omp statusline with a configurable multi-line bar",
24
+ "extensions": [
25
+ "./src/index.ts"
26
+ ]
27
+ },
28
+ "pi": {
29
+ "image": "https://raw.githubusercontent.com/ignissak/omp-statusliney/main/docs/statusline.png"
30
+ },
31
+ "files": [
32
+ "src",
33
+ "README.md"
34
+ ],
35
+ "engines": {
36
+ "bun": ">=1.3.14"
37
+ },
38
+ "peerDependencies": {
39
+ "@oh-my-pi/pi-coding-agent": ">=18.12.2 <19",
40
+ "@oh-my-pi/pi-tui": ">=18.12.2 <19",
41
+ "@oh-my-pi/pi-utils": ">=18.12.2 <19"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@oh-my-pi/pi-coding-agent": {
45
+ "optional": true
46
+ },
47
+ "@oh-my-pi/pi-tui": {
48
+ "optional": true
49
+ },
50
+ "@oh-my-pi/pi-utils": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@oh-my-pi/pi-coding-agent": "18.12.2",
56
+ "@oh-my-pi/pi-tui": "18.12.2",
57
+ "@oh-my-pi/pi-utils": "18.12.2",
58
+ "@types/bun": "latest",
59
+ "typescript": "^5.9.0"
60
+ },
61
+ "scripts": {
62
+ "test": "bun test",
63
+ "typecheck": "tsc --noEmit"
64
+ }
65
+ }
package/src/config.ts ADDED
@@ -0,0 +1,163 @@
1
+ import * as path from "node:path";
2
+ import type { StatusLineSeparatorStyle } from "@oh-my-pi/pi-tui/status-line";
3
+ import { STATUS_LINE_SEPARATOR_VALUES } from "@oh-my-pi/pi-tui/status-line/schema";
4
+ import type { ThemeColor } from "@oh-my-pi/pi-tui/theme";
5
+ import { getAgentDir, isRecord } from "@oh-my-pi/pi-utils";
6
+ import { getWidget, WIDGET_COLOR_TOKENS } from "./widgets/registry";
7
+
8
+ export type Placement = "auto" | "top" | "bottom";
9
+ export type SeparatorChoice = "inherit" | StatusLineSeparatorStyle;
10
+ export type TriState = "inherit" | "on" | "off";
11
+ export type BackgroundChoice = "auto" | "on" | "off";
12
+ export type OptionValue = string | number | boolean;
13
+
14
+ export interface WidgetItem {
15
+ id: string;
16
+ type: string;
17
+ color?: ThemeColor;
18
+ bold?: boolean;
19
+ label?: string;
20
+ options?: Record<string, OptionValue>;
21
+ }
22
+
23
+ export interface StatuslineyConfig {
24
+ version: 1;
25
+ placement: Placement;
26
+ separator: SeparatorChoice;
27
+ background: BackgroundChoice;
28
+ hookStatusRows: TriState;
29
+ /** Repaint interval for clock-driven widgets, 250..10000 ms. */
30
+ refreshMs: number;
31
+ /** Git extras refresh interval, 1000..60000 ms. */
32
+ gitRefreshMs: number;
33
+ /** 1..6 lines; a line may be empty. */
34
+ lines: WidgetItem[][];
35
+ }
36
+
37
+ export const PLACEMENTS: readonly Placement[] = ["auto", "top", "bottom"];
38
+ export const SEPARATOR_CHOICES: readonly SeparatorChoice[] = ["inherit", ...STATUS_LINE_SEPARATOR_VALUES];
39
+ export const BACKGROUND_CHOICES: readonly BackgroundChoice[] = ["auto", "on", "off"];
40
+ export const TRI_STATES: readonly TriState[] = ["inherit", "on", "off"];
41
+ export const MAX_LINES = 6;
42
+ export const REFRESH_RANGE = { min: 250, max: 10_000, default: 1_000 } as const;
43
+ export const GIT_REFRESH_RANGE = { min: 1_000, max: 60_000, default: 5_000 } as const;
44
+
45
+ export function defaultConfigPath(): string {
46
+ return process.env.OMP_STATUSLINEY_CONFIG ?? path.join(getAgentDir(), "statusliney.json");
47
+ }
48
+
49
+ function pick<T extends string>(values: readonly T[], value: unknown, fallback: T): T {
50
+ return values.find(candidate => candidate === value) ?? fallback;
51
+ }
52
+
53
+ function clampInt(value: unknown, range: { min: number; max: number; default: number }): number {
54
+ if (typeof value !== "number" || !Number.isFinite(value)) return range.default;
55
+ return Math.min(range.max, Math.max(range.min, Math.round(value)));
56
+ }
57
+
58
+ /** Validate one option value against its definition; undefined drops it. */
59
+ export function normalizeOptionValue(
60
+ def: { kind: string; values?: readonly string[]; min?: number; max?: number },
61
+ value: unknown,
62
+ ): OptionValue | undefined {
63
+ switch (def.kind) {
64
+ case "bool":
65
+ return typeof value === "boolean" ? value : undefined;
66
+ case "enum":
67
+ return typeof value === "string" && value !== "inherit" && def.values?.includes(value) ? value : undefined;
68
+ case "number": {
69
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
70
+ const rounded = Math.round(value);
71
+ return Math.min(def.max ?? rounded, Math.max(def.min ?? rounded, rounded));
72
+ }
73
+ case "string":
74
+ return typeof value === "string" ? value : undefined;
75
+ default:
76
+ return undefined;
77
+ }
78
+ }
79
+
80
+ function normalizeItem(raw: unknown, warnings: string[]): WidgetItem | undefined {
81
+ if (!isRecord(raw) || typeof raw.type !== "string") {
82
+ warnings.push("statusliney: dropped a widget without a type");
83
+ return undefined;
84
+ }
85
+ const def = getWidget(raw.type);
86
+ if (!def) {
87
+ warnings.push(`statusliney: unknown widget type "${raw.type}" dropped`);
88
+ return undefined;
89
+ }
90
+ const item: WidgetItem = {
91
+ id: typeof raw.id === "string" && raw.id ? raw.id : crypto.randomUUID(),
92
+ type: raw.type,
93
+ };
94
+ if (raw.color !== undefined) {
95
+ const color = WIDGET_COLOR_TOKENS.find(token => token === raw.color);
96
+ if (color) item.color = color;
97
+ else warnings.push(`statusliney: unknown color "${String(raw.color)}" dropped from ${raw.type}`);
98
+ }
99
+ if (typeof raw.bold === "boolean") item.bold = raw.bold;
100
+ if (typeof raw.label === "string" && raw.label) item.label = raw.label;
101
+ if (isRecord(raw.options)) {
102
+ const options: Record<string, OptionValue> = {};
103
+ for (const [key, value] of Object.entries(raw.options)) {
104
+ const optionDef = def.options.find(option => option.key === key);
105
+ const normalized = optionDef ? normalizeOptionValue(optionDef, value) : undefined;
106
+ if (normalized === undefined) {
107
+ warnings.push(`statusliney: invalid option "${key}" dropped from ${raw.type}`);
108
+ continue;
109
+ }
110
+ options[key] = normalized;
111
+ }
112
+ if (Object.keys(options).length > 0) item.options = options;
113
+ }
114
+ return item;
115
+ }
116
+
117
+ /** Type-guarded validation with defaults; never throws. */
118
+ export function normalizeConfig(
119
+ raw: unknown,
120
+ fallbackLines: () => WidgetItem[][],
121
+ ): { config: StatuslineyConfig; warnings: string[]; linesFallback: boolean } {
122
+ const warnings: string[] = [];
123
+ const source = isRecord(raw) ? raw : {};
124
+ let lines: WidgetItem[][] = [];
125
+ const linesFallback = !Array.isArray(source.lines);
126
+ if (Array.isArray(source.lines)) {
127
+ const rawLines: unknown[] = source.lines;
128
+ for (const [index, line] of rawLines.entries()) {
129
+ if (!Array.isArray(line)) {
130
+ warnings.push(`statusliney: line ${index + 1} is not a list of widgets; dropped`);
131
+ continue;
132
+ }
133
+ if (lines.length === MAX_LINES) {
134
+ warnings.push(`statusliney: only the first ${MAX_LINES} lines are used`);
135
+ break;
136
+ }
137
+ const items: WidgetItem[] = [];
138
+ for (const entry of line) {
139
+ const item = normalizeItem(entry, warnings);
140
+ if (item) items.push(item);
141
+ }
142
+ lines.push(items);
143
+ }
144
+ if (lines.length === 0) lines = [[]];
145
+ } else {
146
+ if (source.lines !== undefined) warnings.push("statusliney: invalid lines; using the built-in layout");
147
+ lines = fallbackLines();
148
+ }
149
+ return {
150
+ config: {
151
+ version: 1,
152
+ placement: pick(PLACEMENTS, source.placement, "auto"),
153
+ separator: pick(SEPARATOR_CHOICES, source.separator, "inherit"),
154
+ background: pick(BACKGROUND_CHOICES, source.background, "auto"),
155
+ hookStatusRows: pick(TRI_STATES, source.hookStatusRows, "inherit"),
156
+ refreshMs: clampInt(source.refreshMs, REFRESH_RANGE),
157
+ gitRefreshMs: clampInt(source.gitRefreshMs, GIT_REFRESH_RANGE),
158
+ lines,
159
+ },
160
+ warnings,
161
+ linesFallback,
162
+ };
163
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,288 @@
1
+ import { settings } from "@oh-my-pi/pi-coding-agent/config/settings";
2
+ import { cfgComposerShape } from "@oh-my-pi/pi-coding-agent/modes/settings";
3
+ import { type ComposerStyle, truncateToWidth } from "@oh-my-pi/pi-tui";
4
+ import { sanitizeHookStatusText } from "@oh-my-pi/pi-tui/chrome/shared";
5
+ import { getComposerStyle } from "@oh-my-pi/pi-tui/components/composer/registry";
6
+ import {
7
+ getPreset,
8
+ getSeparator,
9
+ SEGMENTS,
10
+ type StatusLineComponent,
11
+ type StatusLineSegmentId,
12
+ type StatusLineSegmentOptions,
13
+ } from "@oh-my-pi/pi-tui/status-line";
14
+ import { theme } from "@oh-my-pi/pi-tui/theme";
15
+ import type { StatuslineyConfig, WidgetItem } from "./config";
16
+ import { type LayoutPart, type LayoutStyle, layoutLine } from "./layout";
17
+ import type { ConfigStore } from "./store";
18
+ import {
19
+ captureContext,
20
+ type Renderer,
21
+ realStatusLineSettings,
22
+ type SlotSurface,
23
+ takeoverState,
24
+ } from "./takeover";
25
+ import { CONTEXT_SEGMENTS, embedsContext } from "./widgets/builtin";
26
+ import { GIT_EXTRA_TYPES } from "./widgets/git";
27
+ import { getWidget } from "./widgets/registry";
28
+ import type { BuiltinPrefs, PluginData, RenderEnv, Surface, WidgetDef } from "./widgets/types";
29
+
30
+ const TRANSPARENT_BG = "\x1b[49m";
31
+
32
+ type Attachment = ComposerStyle["statusAttachment"];
33
+
34
+ export interface FrameRenderer extends Renderer {
35
+ /** Lines for the above-editor widget. */
36
+ aboveLines(width: number): string[];
37
+ /** Every line on the surface it really uses, for the settings preview. */
38
+ previewLines(width: number): string[];
39
+ requestRender(): void;
40
+ }
41
+
42
+ function isSegmentId(type: string): type is StatusLineSegmentId {
43
+ return Object.hasOwn(SEGMENTS, type);
44
+ }
45
+
46
+ /** Replicates the built-in `#computeEffectiveSettings` for the display prefs widgets honour. */
47
+ export function builtinPrefs(inst: StatusLineComponent | undefined): BuiltinPrefs {
48
+ const user = (inst && takeoverState().userSettings.get(inst)) ?? realStatusLineSettings();
49
+ const preset = user.preset ?? "default";
50
+ const presetDef = getPreset(preset);
51
+ const segmentOptions: Record<string, Record<string, unknown>> = {};
52
+ for (const [segment, options] of Object.entries(presetDef.segmentOptions ?? {})) {
53
+ segmentOptions[segment] = { ...options };
54
+ }
55
+ for (const [segment, options] of Object.entries(user.segmentOptions ?? {})) {
56
+ segmentOptions[segment] = { ...segmentOptions[segment], ...options };
57
+ }
58
+ return {
59
+ preset,
60
+ separator: user.separator ?? presetDef.separator,
61
+ transparent: user.transparent === true,
62
+ sessionAccent: user.sessionAccent !== false,
63
+ compactThinkingLevel: user.compactThinkingLevel ?? false,
64
+ contextLine: user.contextLine ?? "embedded",
65
+ showHookStatus: user.showHookStatus ?? true,
66
+ segmentOptions: segmentOptions as StatusLineSegmentOptions, // per-segment records built from typed sources
67
+ };
68
+ }
69
+
70
+ function lineStyle(config: StatuslineyConfig, builtin: BuiltinPrefs, surface: Surface): LayoutStyle {
71
+ let bg: string;
72
+ if (config.background === "on") bg = theme.getBgAnsi("statusLineBg");
73
+ else if (config.background === "off") bg = TRANSPARENT_BG;
74
+ else bg = surface !== "free" && !builtin.transparent ? theme.getBgAnsi("statusLineBg") : TRANSPARENT_BG;
75
+ const def = getSeparator(config.separator === "inherit" ? builtin.separator : config.separator, theme);
76
+ const caps = def.endCaps && bg !== TRANSPARENT_BG ? def.endCaps : undefined;
77
+ return {
78
+ bg,
79
+ fg: theme.getFgAnsi("text"),
80
+ sep: theme.getFgAnsi("statusLineSep"),
81
+ sepLeft: def.left,
82
+ sepRight: def.right,
83
+ caps,
84
+ bandCap: surface === "band" && caps ? theme.sep.powerlineCapLeft : undefined,
85
+ };
86
+ }
87
+
88
+ function styleContent(item: WidgetItem, raw: string): string {
89
+ let text = item.color ? theme.fg(item.color, Bun.stripANSI(raw)) : raw;
90
+ if (item.bold) text = theme.bold(text);
91
+ if (item.label) text = (item.color ? theme.fg(item.color, item.label) : item.label) + text;
92
+ return text;
93
+ }
94
+
95
+ interface Entry {
96
+ item: WidgetItem;
97
+ def: WidgetDef;
98
+ text?: string;
99
+ }
100
+
101
+ /** Render one config line on `surface`; "" when nothing is visible. */
102
+ export function renderLine(items: readonly WidgetItem[], env: RenderEnv, config: StatuslineyConfig): string {
103
+ const entries: Entry[] = [];
104
+ for (const item of items) {
105
+ const def = getWidget(item.type);
106
+ if (!def) continue;
107
+ if (def.kind === "flex") {
108
+ entries.push({ item, def });
109
+ continue;
110
+ }
111
+ const raw = def.render(item, env);
112
+ if (!raw) continue;
113
+ entries.push({ item, def, text: def.kind === "content" ? styleContent(item, raw) : raw });
114
+ }
115
+
116
+ let lineEnv = env;
117
+ if (env.ctx.contextWindow > 0 && items.some(item => embedsContext(item, env))) {
118
+ const content = entries.filter(entry => entry.def.kind === "content");
119
+ const hasContext = content.some(entry => CONTEXT_SEGMENTS[entry.item.type]);
120
+ const hasOther = content.some(entry => !CONTEXT_SEGMENTS[entry.item.type]);
121
+ if (hasContext && hasOther) {
122
+ lineEnv = { ...env, embedContext: true };
123
+ for (let i = entries.length - 1; i >= 0; i--) {
124
+ if (entries[i].def.kind === "content" && CONTEXT_SEGMENTS[entries[i].item.type]) entries.splice(i, 1);
125
+ }
126
+ }
127
+ }
128
+
129
+ const parts: LayoutPart[] = entries.map(({ item, def, text }) => {
130
+ if (def.kind === "flex") {
131
+ return {
132
+ id: item.id,
133
+ type: item.type,
134
+ kind: "flex",
135
+ text: "",
136
+ fill: width => def.renderFlex?.(item, lineEnv, width) ?? " ".repeat(width),
137
+ minWidth: def.minFlexWidth?.(item, lineEnv),
138
+ };
139
+ }
140
+ const reshrink = def.reshrink;
141
+ return {
142
+ id: item.id,
143
+ type: item.type,
144
+ kind: def.kind,
145
+ text: text ?? "",
146
+ elastic: def.elastic,
147
+ reshrink: reshrink
148
+ ? maxLength => {
149
+ const raw = reshrink(item, lineEnv, maxLength);
150
+ return raw ? styleContent(item, raw) : null;
151
+ }
152
+ : undefined,
153
+ };
154
+ });
155
+ if (!parts.some(part => part.kind !== "flex")) return "";
156
+ return layoutLine(parts, env.width, lineStyle(config, env.builtin, env.surface));
157
+ }
158
+
159
+ function slotFor(attachment: Attachment): SlotSurface | undefined {
160
+ if (attachment === "top-band") return "band";
161
+ if (attachment === "top-border") return "box";
162
+ return undefined;
163
+ }
164
+
165
+ export function createFrameRenderer(store: ConfigStore, data: PluginData): FrameRenderer {
166
+ const state = takeoverState();
167
+ let memo: { inst: StatusLineComponent; lines: Map<string, string[]> } | undefined;
168
+
169
+ const requestRender = (): void => {
170
+ state.tui?.requestRender();
171
+ };
172
+
173
+ const attachmentOf = (inst: StatusLineComponent): Attachment =>
174
+ state.styles.get(inst)?.statusAttachment ?? getComposerStyle(cfgComposerShape.get(settings)).statusAttachment;
175
+
176
+ const placementOf = (attachment: Attachment): { mode: "top" | "bottom"; slot?: SlotSurface } => {
177
+ const slot = slotFor(attachment);
178
+ const placement = store.get().placement;
179
+ const mode = placement === "auto" ? (slot ? "top" : "bottom") : placement;
180
+ return { mode, slot };
181
+ };
182
+
183
+ /** Every config line rendered on `surface` ("" for hidden lines), memoized for this frame. */
184
+ const renderAll = (inst: StatusLineComponent, surface: Surface, width: number, preview: boolean): string[] => {
185
+ if (memo?.inst !== inst) {
186
+ memo = { inst, lines: new Map() };
187
+ const current = memo;
188
+ queueMicrotask(() => {
189
+ if (memo === current) memo = undefined;
190
+ });
191
+ }
192
+ const key = `${surface}|${width}|${preview}`;
193
+ const cached = memo.lines.get(key);
194
+ if (cached) return cached;
195
+ const ctx = captureContext(inst);
196
+ let lines: string[] = [];
197
+ if (ctx) {
198
+ const config = store.get();
199
+ const env: RenderEnv = {
200
+ ctx,
201
+ component: inst,
202
+ theme,
203
+ width,
204
+ surface,
205
+ builtin: builtinPrefs(inst),
206
+ data,
207
+ preview,
208
+ requestRender,
209
+ };
210
+ lines = config.lines.map(items => renderLine(items, env, config));
211
+ }
212
+ memo.lines.set(key, lines);
213
+ return lines;
214
+ };
215
+
216
+ const nonEmpty = (lines: readonly string[]): string[] => lines.filter(line => line !== "");
217
+
218
+ const preview = (
219
+ inst: StatusLineComponent,
220
+ width: number,
221
+ style: Pick<ComposerStyle, "statusAttachment"> | undefined,
222
+ isPreview: boolean,
223
+ ): string[] => {
224
+ const { mode, slot } = placementOf(style?.statusAttachment ?? attachmentOf(inst));
225
+ return nonEmpty(renderAll(inst, mode === "top" && slot ? slot : "free", width, isPreview));
226
+ };
227
+
228
+ return {
229
+ requestRender,
230
+
231
+ requiredSegments() {
232
+ const required: StatusLineSegmentId[] = ["session"];
233
+ let needsGit = false;
234
+ for (const line of store.get().lines) {
235
+ for (const item of line) {
236
+ if (GIT_EXTRA_TYPES[item.type]) needsGit = true;
237
+ if (isSegmentId(item.type) && !required.includes(item.type)) required.push(item.type);
238
+ }
239
+ }
240
+ if (needsGit && !required.includes("git")) required.push("git");
241
+ return required;
242
+ },
243
+
244
+ slot(inst, surface, width) {
245
+ const { mode, slot } = placementOf(attachmentOf(inst));
246
+ if (mode !== "top" || slot !== surface) return "";
247
+ const lines = renderAll(inst, surface, width, false);
248
+ return lines[lines.length - 1] ?? "";
249
+ },
250
+
251
+ bottom(inst, width) {
252
+ const out: string[] = [];
253
+ const { mode } = placementOf(attachmentOf(inst));
254
+ if (mode === "bottom" && !state.autocomplete.get(inst)?.()) {
255
+ const lines = nonEmpty(renderAll(inst, "free", width, false));
256
+ if (lines.length > 0) {
257
+ if (state.styles.get(inst)?.bottomBarGap) out.push("");
258
+ out.push(...lines);
259
+ }
260
+ }
261
+ const rows = store.get().hookStatusRows;
262
+ const showHooks = rows === "inherit" ? builtinPrefs(inst).showHookStatus : rows === "on";
263
+ if (showHooks) {
264
+ const ctx = captureContext(inst);
265
+ for (const text of ctx?.hookStatuses ?? []) out.push(truncateToWidth(sanitizeHookStatusText(text), width));
266
+ }
267
+ return out;
268
+ },
269
+
270
+ aboveLines(width) {
271
+ const inst = state.lastInstance;
272
+ if (!inst) return [];
273
+ const { mode, slot } = placementOf(attachmentOf(inst));
274
+ if (mode !== "top") return [];
275
+ if (!slot) return nonEmpty(renderAll(inst, "free", width, false));
276
+ return nonEmpty(renderAll(inst, slot, width, false).slice(0, -1));
277
+ },
278
+
279
+ previewForStyle(inst, width, style) {
280
+ return preview(inst, width, style, false);
281
+ },
282
+
283
+ previewLines(width) {
284
+ const inst = state.lastInstance;
285
+ return inst ? preview(inst, width, undefined, true) : [];
286
+ },
287
+ };
288
+ }