pi-spark 0.1.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,113 @@
1
+ import { PRESET_CHANGE } from "../shared/events";
2
+ import { formatModel } from "../shared/format";
3
+
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { PresetsConfig, PresetConfig } from "./config";
6
+
7
+ export class PresetManager {
8
+ private pi: ExtensionAPI;
9
+ private presets: PresetsConfig;
10
+ private active: string | undefined = undefined;
11
+
12
+ constructor(pi: ExtensionAPI, presets: PresetsConfig) {
13
+ this.pi = pi;
14
+ this.presets = presets;
15
+ }
16
+
17
+ get keys(): string[] {
18
+ return Object.keys(this.presets);
19
+ }
20
+
21
+ isActive(ctx: ExtensionContext, key: string): boolean {
22
+ const current = this.getCurrentPreset(ctx);
23
+ return key === this.findKey(current);
24
+ }
25
+
26
+ sync(ctx: ExtensionContext): void {
27
+ const current = this.getCurrentPreset(ctx);
28
+ const currentKey = this.findKey(current);
29
+ if (currentKey === this.active) return;
30
+
31
+ this.active = currentKey;
32
+ this.pi.events.emit(PRESET_CHANGE, this.active);
33
+ }
34
+
35
+ async apply(key: string, ctx: ExtensionContext): Promise<boolean> {
36
+ const preset = this.presets[key];
37
+ if (!preset) {
38
+ ctx.ui.notify(`Unknown preset ${key}`, "error");
39
+ return false;
40
+ }
41
+
42
+ const model = ctx.modelRegistry.find(preset.provider, preset.model);
43
+ if (!model) {
44
+ ctx.ui.notify(`Preset ${key}: model ${preset.provider}/${preset.model} not found`, "error");
45
+ return false;
46
+ }
47
+
48
+ const success = await this.pi.setModel(model);
49
+ if (!success) {
50
+ ctx.ui.notify(`Preset ${key}: no API key for ${preset.provider}/${preset.model}`, "error");
51
+ return false;
52
+ }
53
+
54
+ this.pi.setThinkingLevel(preset.thinkingLevel);
55
+
56
+ this.active = this.findKey({
57
+ provider: model.provider,
58
+ model: model.id,
59
+ thinkingLevel: this.pi.getThinkingLevel(),
60
+ });
61
+ this.pi.events.emit(PRESET_CHANGE, this.active);
62
+
63
+ if (this.active === key) {
64
+ ctx.ui.notify(`Preset: ${key} (${this.describe(key)})`, "info");
65
+ } else {
66
+ ctx.ui.notify(`Preset ${key}: thinking level ${preset.thinkingLevel} was clamped to ${this.pi.getThinkingLevel()}`, "warning");
67
+ }
68
+
69
+ return true;
70
+ }
71
+
72
+ async cycle(ctx: ExtensionContext, direction: "forward" | "backward"): Promise<void> {
73
+ if (this.keys.length === 0) {
74
+ ctx.ui.notify("No presets defined in spark.json", "warning");
75
+ return;
76
+ }
77
+
78
+ const current = this.getCurrentPreset(ctx);
79
+ const currentKey = this.findKey(current);
80
+ const currentIndex = currentKey ? this.keys.indexOf(currentKey) : -1;
81
+ const step = direction === "forward" ? 1 : -1;
82
+ const nextIndex = currentIndex === -1 ? direction === "forward" ? 0 : this.keys.length - 1 : (currentIndex + step + this.keys.length) % this.keys.length;
83
+ const nextKey = this.keys[nextIndex];
84
+ if (!nextKey) return;
85
+
86
+ await this.apply(nextKey, ctx);
87
+ }
88
+
89
+ describe(key: string): string {
90
+ const preset = this.presets[key];
91
+ return preset ? formatModel(preset.provider, preset.model, preset.thinkingLevel) : "";
92
+ }
93
+
94
+ private findKey(preset: PresetConfig | undefined): string | undefined {
95
+ if (!preset) return;
96
+
97
+ return this.keys.find((key) => {
98
+ const p = this.presets[key]!;
99
+
100
+ return p.provider === preset.provider && p.model === preset.model && p.thinkingLevel === preset.thinkingLevel;
101
+ });
102
+ }
103
+
104
+ private getCurrentPreset(ctx: ExtensionContext): PresetConfig | undefined {
105
+ if (!ctx.model) return undefined;
106
+
107
+ return {
108
+ provider: ctx.model.provider,
109
+ model: ctx.model.id,
110
+ thinkingLevel: this.pi.getThinkingLevel(),
111
+ };
112
+ }
113
+ }
@@ -0,0 +1,55 @@
1
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
2
+ import { Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
3
+
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { PresetManager } from "./manager";
6
+
7
+ export async function showPresetSelector(ctx: ExtensionContext, presetManager: PresetManager): Promise<string | undefined> {
8
+ if (presetManager.keys.length === 0) {
9
+ ctx.ui.notify("No presets defined in spark.json", "warning");
10
+ return undefined;
11
+ }
12
+
13
+ const selected = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
14
+ const items = presetManager.keys
15
+ .toSorted((a, b) => Number(presetManager.isActive(ctx, b)) - Number(presetManager.isActive(ctx, a)))
16
+ .map((key) => ({
17
+ value: key,
18
+ label: presetManager.isActive(ctx, key) ? `${key} ${theme.fg("success", "✓")} ` : key,
19
+ description: presetManager.describe(key),
20
+ }));
21
+
22
+ const container = new Container();
23
+
24
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
25
+ container.addChild(new Spacer(1));
26
+ container.addChild(new Text(theme.bold("Select preset"), 0, 0));
27
+ container.addChild(new Text(theme.fg("dim", "↑↓ navigate · enter select · esc cancel"), 0, 0));
28
+ container.addChild(new Spacer(1));
29
+
30
+ const selectList = new SelectList(items, 10, {
31
+ selectedPrefix: (text) => theme.fg("accent", text),
32
+ selectedText: (text) => theme.fg("accent", text),
33
+ description: (text) => theme.fg("muted", text),
34
+ scrollInfo: (text) => theme.fg("dim", text),
35
+ noMatch: (text) => theme.fg("warning", text),
36
+ });
37
+ selectList.onSelect = (item) => done(item.value);
38
+ selectList.onCancel = () => done(null);
39
+ container.addChild(selectList);
40
+
41
+ container.addChild(new Spacer(1));
42
+ container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
43
+
44
+ return {
45
+ render: (width: number) => container.render(width),
46
+ invalidate: () => container.invalidate(),
47
+ handleInput: (data: string) => {
48
+ selectList.handleInput(data);
49
+ tui.requestRender();
50
+ },
51
+ };
52
+ });
53
+
54
+ return selected ?? undefined;
55
+ }
@@ -0,0 +1,10 @@
1
+ import * as z from "zod";
2
+
3
+ import { idleTimeoutSchema } from "./idle";
4
+ import { optionalModelSchema } from "../shared/config/model";
5
+
6
+ export const recapConfigSchema = optionalModelSchema.extend({
7
+ idle: idleTimeoutSchema.optional(),
8
+ });
9
+
10
+ export type RecapConfig = z.infer<typeof recapConfigSchema>;
@@ -0,0 +1,106 @@
1
+ import * as z from "zod";
2
+
3
+ export const idleTimeoutSchema = z.number().min(5000);
4
+
5
+ type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
6
+ type IdleHash = string | number | boolean;
7
+
8
+ const DEFAULT_IDLE_MS = 3 * 60 * 1000;
9
+ const POLL_MS = 1_000;
10
+
11
+ export class IdleListener<T> {
12
+ private state: "active" | "watching" | "idle" = "active";
13
+ private computeStateHash: (ctx: T) => IdleHash;
14
+ private lastStateHash: IdleHash | undefined;
15
+
16
+ private idleMs: number;
17
+ private pollMs: number;
18
+ private idleTimer: ReturnType<typeof setTimeout> | undefined;
19
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
20
+
21
+ private enterCallbacks: Set<(ctx: T) => void> = new Set();
22
+ private wakeCallbacks: Set<(ctx: T) => void> = new Set();
23
+
24
+ constructor(computeStateHash: (ctx: T) => IdleHash, idleMs: IdleTimeout = DEFAULT_IDLE_MS, pollMs: number = POLL_MS) {
25
+ this.computeStateHash = computeStateHash;
26
+ this.idleMs = idleMs;
27
+ this.pollMs = pollMs;
28
+ }
29
+
30
+ on(event: "enter" | "wake", callback: (ctx: T) => void): () => void {
31
+ const callbackSet = event === "enter" ? this.enterCallbacks : this.wakeCallbacks;
32
+ callbackSet.add(callback);
33
+
34
+ return () => callbackSet.delete(callback);
35
+ }
36
+
37
+ watch(ctx: T): void {
38
+ if (this.state === "idle") return;
39
+
40
+ this.stop();
41
+ this.state = "watching";
42
+
43
+ this.lastStateHash = this.computeStateHash(ctx);
44
+ this.reset(ctx);
45
+
46
+ this.pollTimer = setInterval(() => {
47
+ const current = this.computeStateHash(ctx);
48
+ if (current === this.lastStateHash) return;
49
+
50
+ this.lastStateHash = current;
51
+ this.reset(ctx);
52
+ }, this.pollMs);
53
+ }
54
+
55
+ enter(ctx: T): void {
56
+ if (this.state === "idle") return;
57
+
58
+ this.stop();
59
+ this.state = "idle";
60
+
61
+ this.enterCallbacks.forEach((callback) => callback(ctx));
62
+ }
63
+
64
+ /** Emit on every wake signal, even when the listener is already active. */
65
+ wake(ctx: T): void {
66
+ this.stop();
67
+ this.state = "active";
68
+
69
+ this.wakeCallbacks.forEach((callback) => callback(ctx));
70
+ }
71
+
72
+ dispose(): void {
73
+ this.stop();
74
+ this.state = "active";
75
+
76
+ this.enterCallbacks.clear();
77
+ this.wakeCallbacks.clear();
78
+ }
79
+
80
+ private reset(ctx: T): void {
81
+ this.clearIdleTimer();
82
+ this.idleTimer = setTimeout(() => {
83
+ this.idleTimer = undefined;
84
+ this.enter(ctx);
85
+ }, this.idleMs);
86
+ }
87
+
88
+ private stop(): void {
89
+ this.clearIdleTimer();
90
+ this.clearPollTimer();
91
+ }
92
+
93
+ private clearIdleTimer(): void {
94
+ if (!this.idleTimer) return;
95
+
96
+ clearTimeout(this.idleTimer);
97
+ this.idleTimer = undefined;
98
+ }
99
+
100
+ private clearPollTimer(): void {
101
+ if (!this.pollTimer) return;
102
+
103
+ clearInterval(this.pollTimer);
104
+ this.pollTimer = undefined;
105
+ }
106
+ }
@@ -0,0 +1,72 @@
1
+ import { IdleListener } from "./idle";
2
+ import { RecapManager } from "./manager";
3
+ import { loadConfig } from "../shared/config";
4
+
5
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ let idleListener: IdleListener<ExtensionContext> | undefined = undefined;
9
+ let recapManager: RecapManager | undefined = undefined;
10
+
11
+ pi.on("session_start", (event, ctx) => {
12
+ if (!ctx.hasUI) return;
13
+
14
+ const config = loadConfig(ctx, "recap");
15
+ if (!config) return;
16
+
17
+ recapManager = new RecapManager(pi, config);
18
+
19
+ pi.registerCommand("recap", {
20
+ description: "Generate a short recap of the current session",
21
+ handler: async () => await recapManager?.run(ctx, { force: true }),
22
+ });
23
+
24
+ idleListener = new IdleListener((c) => `idle:${c.isIdle()};editor:${c.ui.getEditorText()}`, config.idle);
25
+ idleListener.on("enter", (c) => recapManager?.run(c));
26
+ idleListener.on("wake", (c) => recapManager?.clear(c));
27
+
28
+ if (event.reason === "resume" || event.reason === "fork") {
29
+ idleListener.watch(ctx);
30
+ }
31
+ });
32
+
33
+ pi.on("input", (_event, ctx) => {
34
+ idleListener?.wake(ctx);
35
+ });
36
+
37
+ pi.on("user_bash", (_event, ctx) => {
38
+ idleListener?.wake(ctx);
39
+ });
40
+
41
+ pi.on("agent_start", (_event, ctx) => {
42
+ idleListener?.wake(ctx);
43
+ });
44
+
45
+ pi.on("session_before_compact", (_event, ctx) => {
46
+ idleListener?.wake(ctx);
47
+ });
48
+
49
+ pi.on("session_before_tree", (_event, ctx) => {
50
+ idleListener?.wake(ctx);
51
+ });
52
+
53
+ pi.on("agent_end", (_event, ctx) => {
54
+ idleListener?.watch(ctx);
55
+ });
56
+
57
+ pi.on("session_compact", (_event, ctx) => {
58
+ idleListener?.watch(ctx);
59
+ });
60
+
61
+ pi.on("session_tree", (_event, ctx) => {
62
+ idleListener?.watch(ctx);
63
+ });
64
+
65
+ pi.on("session_shutdown", (_event, ctx) => {
66
+ recapManager?.clear(ctx);
67
+ recapManager = undefined;
68
+
69
+ idleListener?.dispose();
70
+ idleListener = undefined;
71
+ });
72
+ }
@@ -0,0 +1,127 @@
1
+ import { completeSimple } from "@earendil-works/pi-ai";
2
+ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { resolveRecapModel } from "./model";
5
+ import { clearRecapWidget, setRecapLoadingWidget, setRecapTextWidget } from "./widget";
6
+ import { sanitizeText } from "../shared/format";
7
+
8
+ import type { Api, Model, ModelThinkingLevel, SimpleStreamOptions, Usage } from "@earendil-works/pi-ai";
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { RecapConfig } from "./config";
11
+
12
+ const SYSTEM_PROMPT = [
13
+ "You write concise recaps for an idle terminal coding agent session.",
14
+ "Summarize only what is supported by the transcript; do not invent progress, intent, files, or next steps.",
15
+ "Focus on the user's goal, completed work, current state, and likely next step.",
16
+ "Prefer recent context when the session changed direction.",
17
+ "Output one short paragraph of 1-2 sentences. No heading, markdown, bullets, or quotes.",
18
+ ].join("\n");
19
+
20
+ const MAX_TOKENS = 120;
21
+ const MAX_CONVERSATION_CHARS = 8_000;
22
+
23
+ export class RecapManager {
24
+ private pi: ExtensionAPI;
25
+ private config: RecapConfig;
26
+ private inflight: AbortController | undefined;
27
+ private active = false;
28
+
29
+ constructor(pi: ExtensionAPI, config: RecapConfig) {
30
+ this.pi = pi;
31
+ this.config = config;
32
+ }
33
+
34
+ async run(ctx: ExtensionContext, options: { force?: boolean } = {}): Promise<void> {
35
+ if (this.active && !options.force) return;
36
+
37
+ this.cancelInflight();
38
+ const controller = new AbortController();
39
+ this.inflight = controller;
40
+
41
+ try {
42
+ const recapModel = await resolveRecapModel(this.pi, ctx, this.config);
43
+ if (controller.signal.aborted || this.inflight !== controller || !recapModel) return;
44
+
45
+ setRecapLoadingWidget(ctx, recapModel.warning);
46
+ this.active = false;
47
+
48
+ const result = await this.generate(ctx, recapModel.model, recapModel.thinkingLevel, controller.signal);
49
+ if (controller.signal.aborted || this.inflight !== controller) return;
50
+ if (!result.content) {
51
+ clearRecapWidget(ctx);
52
+ return;
53
+ }
54
+
55
+ setRecapTextWidget(ctx, result.content, recapModel.warning);
56
+ this.active = true;
57
+
58
+ this.pi.appendEntry("recap", {
59
+ provider: recapModel.model.provider,
60
+ model: recapModel.model.id,
61
+ usage: result.usage,
62
+ content: result.content,
63
+ });
64
+ } catch (error) {
65
+ if (controller.signal.aborted || this.inflight !== controller) return;
66
+
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ setRecapTextWidget(ctx, "Unable to generate recap.", message);
69
+ this.active = false;
70
+ } finally {
71
+ if (this.inflight === controller) this.inflight = undefined;
72
+ }
73
+ }
74
+
75
+ clear(ctx: ExtensionContext): void {
76
+ this.cancelInflight();
77
+ clearRecapWidget(ctx);
78
+ this.active = false;
79
+ }
80
+
81
+ private async generate(ctx: ExtensionContext, model: Model<Api>, thinkingLevel: ModelThinkingLevel, signal: AbortSignal): Promise<{ content: string; usage: Usage }> {
82
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
83
+ if (!auth.ok) throw new Error(auth.error);
84
+
85
+ const options: SimpleStreamOptions = { maxTokens: MAX_TOKENS, signal };
86
+ if (auth.apiKey) options.apiKey = auth.apiKey;
87
+ if (auth.headers) options.headers = auth.headers;
88
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
89
+
90
+ const response = await completeSimple(model, {
91
+ systemPrompt: SYSTEM_PROMPT,
92
+ messages: [{
93
+ role: "user",
94
+ content: [{ type: "text", text: this.buildPrompt(ctx) }],
95
+ timestamp: Date.now(),
96
+ }],
97
+ }, options);
98
+
99
+ const content = response.content
100
+ .filter((block) => block.type === "text")
101
+ .map((block) => block.text)
102
+ .join("\n")
103
+ .trim();
104
+
105
+ return { content: sanitizeText(content), usage: response.usage };
106
+ }
107
+
108
+ private buildPrompt(ctx: ExtensionContext): string {
109
+ const messages = ctx.sessionManager.getBranch().filter((entry) => entry.type === "message").map((entry) => entry.message);
110
+ const text = serializeConversation(convertToLlm(messages));
111
+ const conversation = text.length > MAX_CONVERSATION_CHARS ? text.slice(-MAX_CONVERSATION_CHARS) : text;
112
+
113
+ return [
114
+ "Create a short recap of this coding agent session for the user to see while the agent is idle.",
115
+ "The transcript may be truncated from the beginning and may start mid-message; account for that uncertainty.",
116
+ "",
117
+ "<conversation>",
118
+ conversation,
119
+ "</conversation>",
120
+ ].join("\n");
121
+ }
122
+
123
+ private cancelInflight(): void {
124
+ this.inflight?.abort();
125
+ this.inflight = undefined;
126
+ }
127
+ }
@@ -0,0 +1,61 @@
1
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
2
+
3
+ import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import type { OptionalModelConfig } from "../shared/config/model";
6
+
7
+ const DEFAULT_THINKING_LEVEL: ModelThinkingLevel = "off";
8
+
9
+ type RecapModel = {
10
+ model: Model<Api>;
11
+ thinkingLevel: ModelThinkingLevel;
12
+ warning: string | undefined;
13
+ };
14
+
15
+ export async function resolveRecapModel(pi: ExtensionAPI, ctx: ExtensionContext, config: OptionalModelConfig): Promise<RecapModel | undefined> {
16
+ const fallbackModel = ctx.model;
17
+ if (!fallbackModel) {
18
+ ctx.ui.notify("No model selected for recap", "warning");
19
+ return;
20
+ }
21
+
22
+ let model = fallbackModel;
23
+ let warning: string | undefined;
24
+
25
+ if (config.provider || config.model) {
26
+ if (!config.provider || !config.model) {
27
+ warning = "Both recap.provider and recap.model are required; using the current model.";
28
+ } else {
29
+ const configuredModel = ctx.modelRegistry.find(config.provider, config.model);
30
+ if (configuredModel) {
31
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(configuredModel);
32
+ if (auth.ok) {
33
+ model = configuredModel;
34
+ } else {
35
+ warning = `Model ${config.provider}/${config.model} unavailable: ${auth.error}; using the current model.`;
36
+ }
37
+ } else {
38
+ warning = `Model ${config.provider}/${config.model} not found; using the current model.`;
39
+ }
40
+ }
41
+ }
42
+
43
+ const { thinkingLevel, warning: thinkingLevelWarning } = resolveThinkingLevel(model, config.thinkingLevel ?? pi.getThinkingLevel());
44
+
45
+ return {
46
+ model,
47
+ thinkingLevel,
48
+ warning: [warning, thinkingLevelWarning].filter(Boolean).join(" ") || undefined,
49
+ };
50
+ }
51
+
52
+ function resolveThinkingLevel(model: Model<Api>, requested: ModelThinkingLevel): { thinkingLevel: ModelThinkingLevel; warning?: string } {
53
+ const thinkingLevel = clampThinkingLevel(model, requested);
54
+ if (thinkingLevel === requested) return { thinkingLevel };
55
+
56
+ const fallback = clampThinkingLevel(model, DEFAULT_THINKING_LEVEL);
57
+ return {
58
+ thinkingLevel: fallback,
59
+ warning: `Thinking level ${requested} is not supported by ${model.provider}/${model.id}; using ${fallback}.`,
60
+ };
61
+ }
@@ -0,0 +1,42 @@
1
+ import { Container, Loader, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ const WIDGET_KEY = "recap";
6
+
7
+ export function setRecapLoadingWidget(ctx: ExtensionContext, warning?: string): void {
8
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
9
+ const loader = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), "Generating recap...");
10
+ loader.start();
11
+
12
+ return {
13
+ render: (width: number) => {
14
+ const lines = loader.render(width);
15
+ if (lines[0] === "") lines.shift();
16
+
17
+ const loaderLine = (lines[0] ?? "").trimEnd();
18
+ const line = `${loaderLine}${warning ? ` ${theme.fg("warning", `(Warning: ${warning})`)}` : ""}`;
19
+
20
+ return [truncateToWidth(line, width), ""];
21
+ },
22
+ invalidate: () => loader.invalidate(),
23
+ dispose: () => loader.stop(),
24
+ };
25
+ });
26
+ }
27
+
28
+ export function setRecapTextWidget(ctx: ExtensionContext, content: string, warning?: string): void {
29
+ ctx.ui.setWidget(WIDGET_KEY, (_tui, theme) => {
30
+ const text = `${theme.bold(theme.fg("muted", "Recap:"))}${theme.fg("muted", ` ${content}`)}${warning ? ` ${theme.fg("warning", `(Warning: ${warning})`)}` : ""}`;
31
+
32
+ const container = new Container();
33
+ container.addChild(new Text(text, 1, 0));
34
+ container.addChild(new Spacer(1));
35
+
36
+ return container;
37
+ });
38
+ }
39
+
40
+ export function clearRecapWidget(ctx: ExtensionContext): void {
41
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
42
+ }
@@ -0,0 +1,56 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ import { sanitizeText } from "../format";
4
+
5
+ import type { Component } from "@earendil-works/pi-tui";
6
+
7
+ interface InlineTextOptions {
8
+ padding?: number;
9
+ spacingChar?: string;
10
+ ellipsis?: string;
11
+ }
12
+
13
+ const DEFAULT_INLINE_TEXT_OPTIONS: Required<InlineTextOptions> = {
14
+ padding: 0,
15
+ spacingChar: " ",
16
+ ellipsis: "…",
17
+ };
18
+
19
+ /**
20
+ * Text component that renders inline and truncates it to fit the given width.
21
+ *
22
+ * The given width is a budget, not a guaranteed rendered width. After rendering, callers should
23
+ * check the result with visibleWidth(), because the rendered width may be smaller than the budget.
24
+ */
25
+ export class InlineText implements Component {
26
+ private text: string;
27
+
28
+ private padding: number;
29
+ private spacingChar: string;
30
+ private ellipsis: string;
31
+
32
+ constructor(text: string, options: InlineTextOptions = {}) {
33
+ const spacingChar = options.spacingChar ?? DEFAULT_INLINE_TEXT_OPTIONS.spacingChar;
34
+ if (visibleWidth(spacingChar) !== 1) throw new Error("spacingChar must have a visible width of 1");
35
+
36
+ this.text = sanitizeText(text);
37
+
38
+ this.padding = options.padding ?? DEFAULT_INLINE_TEXT_OPTIONS.padding;
39
+ this.spacingChar = spacingChar;
40
+ this.ellipsis = options.ellipsis ?? DEFAULT_INLINE_TEXT_OPTIONS.ellipsis;
41
+ }
42
+
43
+ invalidate(): void {
44
+ // No-op
45
+ }
46
+
47
+ render(width: number): [string] {
48
+ if (visibleWidth(this.text) <= 0) return [""];
49
+ if (width < this.padding * 2 + visibleWidth(this.ellipsis)) return [""];
50
+
51
+ const displayText = truncateToWidth(this.text, width - this.padding * 2, this.ellipsis);
52
+ const paddingText = this.spacingChar.repeat(this.padding);
53
+
54
+ return [`${paddingText}${displayText}${paddingText}`];
55
+ }
56
+ }