pi-cliproxyapi 0.1.2 → 0.2.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.
@@ -0,0 +1,247 @@
1
+ // Three-panel picker: state, navigation, render, input dispatch.
2
+ //
3
+ // Layout (drawn by render()):
4
+ // \u250c\u2500 providers \u2500\u252c\u2500 assigned to <prov> \u2500\u2510
5
+ // \u2502 list ... \u2502 list ... \u2502
6
+ // \u2502 \u251c\u2500 available pool \u2500\u2500\u2500\u2500\u2500\u2524
7
+ // \u2502 \u2502 grouped by owned_by \u2502
8
+ // \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518
9
+
10
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ type Component,
13
+ getKeybindings,
14
+ matchesKey,
15
+ visibleWidth,
16
+ } from "@earendil-works/pi-tui";
17
+
18
+ import type { ProxyConfig } from "../config.ts";
19
+ import type { Discovery } from "../fetch-models.ts";
20
+ import { buildCatalog } from "./catalog.ts";
21
+ import {
22
+ apiCompatible,
23
+ assignedIdsFor,
24
+ attachModel,
25
+ detachModel,
26
+ groupPoolByOwnedBy,
27
+ poolFor,
28
+ } from "./mutate.ts";
29
+ import { collectProviders } from "./providers.ts";
30
+ import { confirmRemoveProvider } from "./prompt-confirm.ts";
31
+ import { promptNewProviderName } from "./prompt-name.ts";
32
+ import { pad } from "./render-text.ts";
33
+ import {
34
+ renderEmpty,
35
+ renderModelRow,
36
+ renderNewProviderRow,
37
+ renderPanelHeader,
38
+ renderProviderRow,
39
+ renderSubheader,
40
+ } from "./rows.ts";
41
+ import type {
42
+ CatalogIndex,
43
+ OverlayTui,
44
+ PanelId,
45
+ ProviderEntry,
46
+ Theme,
47
+ } from "./types.ts";
48
+
49
+ interface RightRow {
50
+ kind: "header" | "sub" | "model" | "empty";
51
+ label?: string;
52
+ // model fields
53
+ id?: string;
54
+ side?: "assigned" | "pool";
55
+ compatWarn?: boolean;
56
+ /** index into the linear cursor list (skipping non-cursor rows). */
57
+ cursorIdx?: number;
58
+ }
59
+
60
+ export function buildPicker(
61
+ tui: OverlayTui,
62
+ theme: Theme,
63
+ cfg: ProxyConfig,
64
+ discovery: Discovery,
65
+ ctx: ExtensionCommandContext,
66
+ done: (v: ProxyConfig | null) => void,
67
+ ): Component & { handleInput(data: string): void } {
68
+ const catalog: CatalogIndex = buildCatalog(discovery);
69
+ let providers: ProviderEntry[] = collectProviders(cfg, catalog);
70
+
71
+ let focus: PanelId = "providers";
72
+ let providerCursor = 0;
73
+ let assignedCursor = 0;
74
+ let poolCursor = 0;
75
+ let providerScroll = 0;
76
+ let assignedScroll = 0;
77
+ let poolScroll = 0;
78
+ const finish = (result: ProxyConfig | null): void => {
79
+ done(result);
80
+ };
81
+
82
+ const selectedProvider = (): ProviderEntry | null => {
83
+ if (providers.length === 0) return null;
84
+ const idx = Math.max(0, Math.min(providerCursor, providers.length - 1));
85
+ return providers[idx] ?? null;
86
+ };
87
+
88
+ const refresh = (): void => {
89
+ providers = collectProviders(cfg, catalog);
90
+ const maxProv = Math.max(0, providers.length); // +1 for "+ new" pseudo row
91
+ if (providerCursor > maxProv) providerCursor = maxProv;
92
+ const prov = selectedProvider();
93
+ if (prov) {
94
+ const aLen = assignedIdsFor(cfg, prov).length;
95
+ const pLen = poolFor(cfg, prov, catalog).length;
96
+ if (assignedCursor >= aLen) assignedCursor = Math.max(0, aLen - 1);
97
+ if (poolCursor >= pLen) poolCursor = Math.max(0, pLen - 1);
98
+ }
99
+ };
100
+
101
+ const ensureVisible = (
102
+ cursor: number,
103
+ scroll: number,
104
+ visible: number,
105
+ ): number => {
106
+ if (cursor < scroll) return cursor;
107
+ if (cursor >= scroll + visible) return cursor - visible + 1;
108
+ return Math.max(0, scroll);
109
+ };
110
+
111
+ const onTab = (back: boolean): void => {
112
+ const order: PanelId[] = ["providers", "assigned", "pool"];
113
+ const i = order.indexOf(focus);
114
+ focus = back
115
+ ? order[(i - 1 + order.length) % order.length]!
116
+ : order[(i + 1) % order.length]!;
117
+ };
118
+
119
+ const moveCursor = (delta: number): void => {
120
+ if (focus === "providers") {
121
+ const total = providers.length + 1; // +1 for "+ new" pseudo row
122
+ providerCursor = Math.max(0, Math.min(providerCursor + delta, total - 1));
123
+ return;
124
+ }
125
+ const prov = selectedProvider();
126
+ if (!prov) return;
127
+ if (focus === "assigned") {
128
+ const total = assignedIdsFor(cfg, prov).length;
129
+ if (total === 0) return;
130
+ assignedCursor = Math.max(0, Math.min(assignedCursor + delta, total - 1));
131
+ return;
132
+ }
133
+ const total = poolFor(cfg, prov, catalog).length;
134
+ if (total === 0) return;
135
+ poolCursor = Math.max(0, Math.min(poolCursor + delta, total - 1));
136
+ };
137
+
138
+ const onActivate = async (): Promise<void> => {
139
+ if (focus === "providers") {
140
+ if (providerCursor === providers.length) {
141
+ const name = await promptNewProviderName(ctx, cfg.proxy.providerPrefix);
142
+ if (name && !cfg.customProviders[name]) {
143
+ cfg.customProviders[name] = { api: "openai-completions", models: [] };
144
+ refresh();
145
+ providerCursor = providers.findIndex(
146
+ (p) => p.kind === "custom" && p.name === name,
147
+ );
148
+ if (providerCursor < 0) providerCursor = providers.length - 1;
149
+ focus = "pool";
150
+ }
151
+ tui.requestRender();
152
+ return;
153
+ }
154
+ focus = "pool";
155
+ return;
156
+ }
157
+ const prov = selectedProvider();
158
+ if (!prov) return;
159
+ if (focus === "assigned") {
160
+ const ids = assignedIdsFor(cfg, prov);
161
+ const id = ids[assignedCursor];
162
+ if (!id) return;
163
+ detachModel(cfg, prov, id);
164
+ refresh();
165
+ return;
166
+ }
167
+ const ids = poolFor(cfg, prov, catalog);
168
+ const id = ids[poolCursor];
169
+ if (!id) return;
170
+ const m = catalog.byId.get(id);
171
+ if (!m) return;
172
+ attachModel(cfg, prov, m);
173
+ refresh();
174
+ };
175
+
176
+ const onDelete = async (): Promise<void> => {
177
+ if (focus !== "providers") return;
178
+ const prov = selectedProvider();
179
+ if (!prov || prov.kind !== "custom") return;
180
+ const ok = await confirmRemoveProvider(ctx, prov.name);
181
+ if (!ok) {
182
+ tui.requestRender();
183
+ return;
184
+ }
185
+ delete cfg.customProviders[prov.name];
186
+ refresh();
187
+ if (providerCursor >= providers.length)
188
+ providerCursor = Math.max(0, providers.length - 1);
189
+ tui.requestRender();
190
+ };
191
+
192
+ // Render and input split into the second half of the file.
193
+ return assembleComponent({
194
+ tui,
195
+ theme,
196
+ cfg,
197
+ catalog,
198
+ getProviders: () => providers,
199
+ getFocus: () => focus,
200
+ setFocus: (f: PanelId) => {
201
+ focus = f;
202
+ },
203
+ getProviderCursor: () => providerCursor,
204
+ getAssignedCursor: () => assignedCursor,
205
+ getPoolCursor: () => poolCursor,
206
+ getProviderScroll: () => providerScroll,
207
+ setProviderScroll: (v: number) => {
208
+ providerScroll = v;
209
+ },
210
+ getAssignedScroll: () => assignedScroll,
211
+ setAssignedScroll: (v: number) => {
212
+ assignedScroll = v;
213
+ },
214
+ getPoolScroll: () => poolScroll,
215
+ setPoolScroll: (v: number) => {
216
+ poolScroll = v;
217
+ },
218
+ selectedProvider,
219
+ moveCursor,
220
+ onTab,
221
+ onActivate,
222
+ onDelete,
223
+ ensureVisible,
224
+ finish,
225
+ apiCompatible,
226
+ poolGrouper: (ids: string[]) => groupPoolByOwnedBy(ids, catalog),
227
+ assignedIdsFor: (p: ProviderEntry) => assignedIdsFor(cfg, p),
228
+ poolFor: (p: ProviderEntry) => poolFor(cfg, p, catalog),
229
+ });
230
+ }
231
+
232
+ // The component assembly (render + input) lives next-door to keep this file
233
+ // readable. We import it lazily to dodge circular imports.
234
+ import { assembleComponent } from "./picker-component.ts";
235
+ // Re-export helpers used by the component file (avoid duplicate imports).
236
+ export {
237
+ pad,
238
+ renderEmpty,
239
+ renderModelRow,
240
+ renderNewProviderRow,
241
+ renderPanelHeader,
242
+ renderProviderRow,
243
+ renderSubheader,
244
+ visibleWidth,
245
+ };
246
+ export type { RightRow };
247
+ export { matchesKey, getKeybindings };
@@ -0,0 +1,71 @@
1
+ // Confirm-remove prompt overlay used when the user deletes a custom group.
2
+
3
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ type Component,
6
+ getKeybindings,
7
+ matchesKey,
8
+ } from "@earendil-works/pi-tui";
9
+
10
+ import { frame } from "../ui-frame.ts";
11
+ import type { Theme } from "./types.ts";
12
+
13
+ export async function confirmRemoveProvider(
14
+ ctx: ExtensionCommandContext,
15
+ slug: string,
16
+ ): Promise<boolean> {
17
+ return ctx.ui.custom<boolean>(
18
+ (_tui, theme, _kb, done) =>
19
+ buildConfirmPrompt(
20
+ theme as unknown as Theme,
21
+ `Remove custom group \u201c${slug}\u201d?`,
22
+ "All model assignments in this group will be discarded.",
23
+ done,
24
+ ),
25
+ { overlay: true, overlayOptions: { width: 80, maxHeight: "40%" } },
26
+ );
27
+ }
28
+
29
+ function buildConfirmPrompt(
30
+ theme: Theme,
31
+ title: string,
32
+ body: string,
33
+ done: (v: boolean) => void,
34
+ ): Component & { handleInput(data: string): void } {
35
+ return {
36
+ render(width: number): string[] {
37
+ return frame(theme, {
38
+ width,
39
+ title: " confirm ",
40
+ titleColor: "error",
41
+ lines: ["", ` ${theme.bold(title)}`, ` ${theme.fg("dim", body)}`, ""],
42
+ footer: {
43
+ hint: " y / enter = remove \u00b7 n / esc = cancel ",
44
+ },
45
+ });
46
+ },
47
+ invalidate(): void {
48
+ /* stateless */
49
+ },
50
+ handleInput(data: string): void {
51
+ const kb = getKeybindings();
52
+ if (
53
+ kb.matches(data, "tui.select.cancel") ||
54
+ matchesKey(data, "escape") ||
55
+ matchesKey(data, "n") ||
56
+ matchesKey(data, "q")
57
+ ) {
58
+ done(false);
59
+ return;
60
+ }
61
+ if (
62
+ matchesKey(data, "y") ||
63
+ matchesKey(data, "enter") ||
64
+ matchesKey(data, "return")
65
+ ) {
66
+ done(true);
67
+ return;
68
+ }
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,90 @@
1
+ // "+ new custom group" prompt overlay.
2
+
3
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ type Component,
6
+ getKeybindings,
7
+ Input,
8
+ matchesKey,
9
+ } from "@earendil-works/pi-tui";
10
+
11
+ import { withProviderPrefix } from "../compat.ts";
12
+ import { frame, frameInner } from "../ui-frame.ts";
13
+ import type { Theme } from "./types.ts";
14
+
15
+ export async function promptNewProviderName(
16
+ ctx: ExtensionCommandContext,
17
+ prefix: string | undefined,
18
+ ): Promise<string | null> {
19
+ const suggestion = withProviderPrefix(prefix, "group");
20
+ return ctx.ui.custom<string | null>(
21
+ (tui, theme, _kb, done) =>
22
+ buildNamePrompt(
23
+ tui as unknown as { requestRender(): void },
24
+ theme as unknown as Theme,
25
+ suggestion,
26
+ done,
27
+ ),
28
+ { overlay: true, overlayOptions: { width: 80, maxHeight: "40%" } },
29
+ );
30
+ }
31
+
32
+ function buildNamePrompt(
33
+ tui: { requestRender(): void },
34
+ theme: Theme,
35
+ suggestion: string,
36
+ done: (v: string | null) => void,
37
+ ): Component & { handleInput(data: string): void } {
38
+ const input = new Input();
39
+ if (suggestion) input.setValue(suggestion);
40
+ input.focused = true;
41
+ let error: string | null = null;
42
+
43
+ input.onSubmit = (raw) => {
44
+ const v = raw.trim();
45
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(v)) {
46
+ error = "letters / digits / dot / dash / underscore only";
47
+ tui.requestRender();
48
+ return;
49
+ }
50
+ done(v);
51
+ };
52
+ input.onEscape = () => done(null);
53
+
54
+ return {
55
+ render(width: number): string[] {
56
+ const inner = frameInner(width);
57
+ const hint = suggestion
58
+ ? `name shown in /model picker, e.g. ${suggestion}`
59
+ : "letters / digits / dot / dash / underscore";
60
+ const inputLines = input.render(inner - 4);
61
+ const lines: string[] = [
62
+ "",
63
+ ` ${theme.fg("dim", hint)}`,
64
+ "",
65
+ ...inputLines.map((ln) => ` ${theme.fg("accent", `\u276f ${ln}`)}`),
66
+ "",
67
+ error ? ` ${theme.fg("error", `! ${error}`)}` : "",
68
+ ];
69
+ return frame(theme, {
70
+ width,
71
+ title: " new custom group ",
72
+ lines,
73
+ footer: { hint: " enter = create \u00b7 esc = cancel " },
74
+ });
75
+ },
76
+ invalidate(): void {
77
+ input.invalidate();
78
+ },
79
+ handleInput(data: string): void {
80
+ const kb = getKeybindings();
81
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
82
+ done(null);
83
+ return;
84
+ }
85
+ error = null;
86
+ input.handleInput(data);
87
+ tui.requestRender();
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,68 @@
1
+ // Resolve the list of providers shown in the left panel. The union covers:
2
+ // - every built-in provider that the proxy offers AND/OR pi-ai knows
3
+ // about (so a newly-released provider that's not in the local catalog
4
+ // still surfaces);
5
+ // - every custom group already declared in the user's config.
6
+
7
+ import type { Api } from "@earendil-works/pi-ai";
8
+ import { getModels, getProviders } from "@earendil-works/pi-ai";
9
+
10
+ import type { ProxyConfig } from "../config.ts";
11
+ import type { CatalogIndex, ProviderEntry } from "./types.ts";
12
+
13
+ export function collectProviders(
14
+ cfg: ProxyConfig,
15
+ catalog: CatalogIndex,
16
+ ): ProviderEntry[] {
17
+ const out: ProviderEntry[] = [];
18
+
19
+ const proxyProviderNames = new Set<string>(catalog.builtinModelIds.keys());
20
+ const knownPiAiProviders = new Set<string>(getProviders());
21
+ const builtinNames = new Set<string>([
22
+ ...proxyProviderNames,
23
+ ...knownPiAiProviders,
24
+ ]);
25
+
26
+ for (const name of Array.from(builtinNames).sort()) {
27
+ const proxyIds = catalog.builtinModelIds.get(name);
28
+ if (!proxyIds || proxyIds.length === 0) continue;
29
+
30
+ // Pick the API: pi-ai catalog first, then proxy hint.
31
+ let api: Api | undefined;
32
+ try {
33
+ const localModels = getModels(name as Parameters<typeof getModels>[0]);
34
+ const hit = localModels.find((m) => proxyIds.includes(m.id));
35
+ if (hit) api = hit.api as Api;
36
+ } catch {
37
+ /* unknown provider in pi-ai \u2014 fall back to proxy hint */
38
+ }
39
+ if (!api) {
40
+ const firstId = proxyIds[0]!;
41
+ api = catalog.byId.get(firstId)?.suggestedApi ?? "openai-responses";
42
+ }
43
+
44
+ out.push({
45
+ kind: "builtin",
46
+ name,
47
+ api,
48
+ subtitle: `built-in \u00b7 ${api}`,
49
+ });
50
+
51
+ // Make sure config has a slot so state tracking is straightforward.
52
+ if (!cfg.builtinProviders[name]) {
53
+ cfg.builtinProviders[name] = { enabled: false, models: [] };
54
+ }
55
+ }
56
+
57
+ for (const slug of Object.keys(cfg.customProviders).sort()) {
58
+ const p = cfg.customProviders[slug]!;
59
+ out.push({
60
+ kind: "custom",
61
+ name: slug,
62
+ api: p.api,
63
+ subtitle: `custom \u00b7 ${p.api}`,
64
+ });
65
+ }
66
+
67
+ return out;
68
+ }
@@ -0,0 +1,39 @@
1
+ // Low-level text utilities. Everything here is pure and ANSI-aware.
2
+
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+
5
+ /** Pad a possibly-ANSI string to exactly `width` cells, truncating if needed. */
6
+ export function pad(s: string, width: number): string {
7
+ const w = visibleWidth(s);
8
+ if (w === width) return s;
9
+ if (w < width) return s + " ".repeat(width - w);
10
+ return truncateAnsi(s, width);
11
+ }
12
+
13
+ /**
14
+ * Clip an ANSI-coloured string to `width` visible cells. SGR escapes pass
15
+ * through; a final reset is appended so trailing colour state does not bleed.
16
+ */
17
+ export function truncateAnsi(s: string, width: number): string {
18
+ if (width <= 0) return "\x1b[0m";
19
+ let out = "";
20
+ let visible = 0;
21
+ let i = 0;
22
+ while (i < s.length) {
23
+ const ch = s[i]!;
24
+ if (ch === "\x1b" && s[i + 1] === "[") {
25
+ const end = s.indexOf("m", i + 2);
26
+ if (end >= 0) {
27
+ out += s.slice(i, end + 1);
28
+ i = end + 1;
29
+ continue;
30
+ }
31
+ }
32
+ const w = visibleWidth(ch);
33
+ if (visible + w > width) break;
34
+ out += ch;
35
+ visible += w;
36
+ i++;
37
+ }
38
+ return `${out}\x1b[0m`;
39
+ }
@@ -0,0 +1,151 @@
1
+ // Row renderers for left + right panels. Each function returns a string that
2
+ // is guaranteed to fit into `width` visible cells (via marquee on the focused
3
+ // row, and pad/truncate otherwise).
4
+ //
5
+ // The renderers also draw the cursor differently for the active panel
6
+ // (\u25b6 accent) vs an inactive panel where the cursor row is highlighted
7
+ // but more muted (\u00b7 dim) \u2014 this gives users a hint where focus is
8
+ // without losing track of selection across panels.
9
+
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
+
12
+ import type { ProxyConfig } from "../config.ts";
13
+ import { pad, truncateAnsi } from "./render-text.ts";
14
+ import type { ModelEntry, ProviderEntry, Theme } from "./types.ts";
15
+
16
+ interface RowCtx {
17
+ theme: Theme;
18
+ width: number;
19
+ isCursor: boolean;
20
+ isFocused: boolean;
21
+ }
22
+
23
+ function cursorMark(
24
+ theme: Theme,
25
+ isCursor: boolean,
26
+ isFocused: boolean,
27
+ ): string {
28
+ if (!isCursor) return " ";
29
+ if (isFocused) return theme.fg("accent", "\u25b6 ");
30
+ return theme.fg("muted", "\u25b8 ");
31
+ }
32
+
33
+ function fitLine(line: string, width: number): string {
34
+ const w = visibleWidth(line);
35
+ if (w === width) return line;
36
+ if (w > width) return truncateAnsi(line, width);
37
+ return pad(line, width);
38
+ }
39
+
40
+ // --------------------------------------------------------------------------- left panel rows
41
+
42
+ export function renderProviderRow(
43
+ p: ProviderEntry,
44
+ cfg: ProxyConfig,
45
+ ctx: RowCtx,
46
+ ): string {
47
+ const { theme, width, isCursor, isFocused } = ctx;
48
+ const mark = cursorMark(theme, isCursor, isFocused);
49
+ const count =
50
+ p.kind === "builtin"
51
+ ? (cfg.builtinProviders[p.name]?.models.length ?? 0)
52
+ : (cfg.customProviders[p.name]?.models.length ?? 0);
53
+
54
+ const tag =
55
+ p.kind === "builtin" ? theme.fg("accent", "B") : theme.fg("warning", "C");
56
+ const tagBracket = `${theme.fg("dim", "[")}${tag}${theme.fg("dim", "]")}`;
57
+
58
+ const dot =
59
+ count > 0 ? theme.fg("success", "\u25cf") : theme.fg("dim", "\u25cb");
60
+ const countStr =
61
+ count > 0
62
+ ? theme.fg("success", String(count).padStart(2))
63
+ : theme.fg("dim", " 0");
64
+
65
+ const name =
66
+ isCursor && isFocused
67
+ ? theme.bold(theme.fg("accent", p.name))
68
+ : isCursor
69
+ ? theme.bold(p.name)
70
+ : p.name;
71
+ const api = theme.fg("dim", p.api);
72
+
73
+ const line = `${mark}${tagBracket} ${name} ${dot}${countStr} ${api}`;
74
+ return fitLine(line, width);
75
+ }
76
+
77
+ export function renderNewProviderRow(
78
+ theme: Theme,
79
+ isCursor: boolean,
80
+ isFocused: boolean,
81
+ width: number,
82
+ ): string {
83
+ const mark = cursorMark(theme, isCursor, isFocused);
84
+ const body =
85
+ isCursor && isFocused
86
+ ? theme.bold(theme.fg("accent", "\uff0b new custom group\u2026"))
87
+ : theme.fg("accent", "\uff0b new custom group\u2026");
88
+ return pad(`${mark}${body}`, width);
89
+ }
90
+
91
+ // --------------------------------------------------------------------------- right panel rows
92
+
93
+ export function renderModelRow(
94
+ id: string,
95
+ m: ModelEntry | undefined,
96
+ side: "assigned" | "pool",
97
+ compatWarn: boolean,
98
+ ctx: RowCtx,
99
+ ): string {
100
+ const { theme, width, isCursor, isFocused } = ctx;
101
+ const mark = cursorMark(theme, isCursor, isFocused);
102
+
103
+ const box =
104
+ side === "assigned"
105
+ ? theme.fg("success", "\u2611")
106
+ : theme.fg("dim", "\u2610");
107
+
108
+ const warn = compatWarn ? ` ${theme.fg("warning", "\u26a0")}` : "";
109
+ const idStr = isCursor && isFocused ? theme.fg("accent", id) : id;
110
+ const reasoning = m?.reasoning ? ` ${theme.fg("accent", "\u2728")}` : "";
111
+ const apiTag = m
112
+ ? theme.fg("muted", ` \u00b7 ${m.suggestedApi}`)
113
+ : theme.fg("error", " \u00b7 not on proxy");
114
+
115
+ const line = `${mark}${box} ${idStr}${warn}${reasoning}${apiTag}`;
116
+ return fitLine(line, width);
117
+ }
118
+
119
+ // --------------------------------------------------------------------------- panel subheaders
120
+
121
+ export function renderSubheader(
122
+ theme: Theme,
123
+ label: string,
124
+ width: number,
125
+ ): string {
126
+ // Compact "── label ──" tag, not a full-width rule. We don't want it to
127
+ // look like another panel frame.
128
+ const tag = `${theme.fg("borderAccent", "\u2500\u2500")} ${theme.fg("warning", label)} ${theme.fg("borderAccent", "\u2500\u2500")}`;
129
+ return pad(` ${tag}`, width);
130
+ }
131
+
132
+ export function renderEmpty(
133
+ theme: Theme,
134
+ label: string,
135
+ width: number,
136
+ ): string {
137
+ return pad(` ${theme.fg("dim", label)}`, width);
138
+ }
139
+
140
+ /** Decorate a header line ("assigned to <name>") with focus emphasis. */
141
+ export function renderPanelHeader(
142
+ theme: Theme,
143
+ text: string,
144
+ width: number,
145
+ isFocused: boolean,
146
+ ): string {
147
+ const prefix = isFocused
148
+ ? theme.bold(theme.fg("accent", "\u275a "))
149
+ : theme.fg("dim", " ");
150
+ return pad(`${prefix}${text}`, width);
151
+ }