@monotykamary/pi-better-openai 0.1.23

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,269 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ sliceByColumn,
7
+ truncateToWidth,
8
+ visibleWidth,
9
+ } from "@earendil-works/pi-tui";
10
+ import type { LivePhase, LiveTranscript } from "./controller.ts";
11
+
12
+ const ANIMATION_INTERVAL_MS = 80;
13
+ const LIVE_TOGGLE_KEY = Key.ctrlShift("l");
14
+ const ANSI_ESCAPE_REGEXP = new RegExp(String.raw`\u001B\[[0-?]*[ -/]*[@-~]`, "g");
15
+
16
+ export interface LiveVisualizerOptions {
17
+ theme: Theme;
18
+ requestRender(): void;
19
+ onStop(): void;
20
+ onToggleMute(): void;
21
+ }
22
+
23
+ type RenderCache = {
24
+ width: number;
25
+ phase: LivePhase;
26
+ displayLevel: number;
27
+ frame: number;
28
+ transcriptRole: LiveTranscript["role"] | undefined;
29
+ transcriptText: string;
30
+ lines: string[];
31
+ };
32
+
33
+ function sanitizeTranscript(text: string): string {
34
+ const withoutAnsi = text.replace(ANSI_ESCAPE_REGEXP, "");
35
+ let safe = "";
36
+ for (const character of withoutAnsi) {
37
+ const code = character.charCodeAt(0);
38
+ safe += code <= 31 || (code >= 127 && code <= 159) ? " " : character;
39
+ }
40
+ return safe.replace(/\s+/g, " ").trim();
41
+ }
42
+
43
+ function truncateFromStart(text: string, width: number): string {
44
+ if (width <= 0) return "";
45
+ const textWidth = visibleWidth(text);
46
+ if (textWidth <= width) return text;
47
+ if (width === 1) return "…";
48
+ return `…${sliceByColumn(text, textWidth - width + 1, width - 1, true)}`;
49
+ }
50
+
51
+ export class LiveVisualizer implements Component {
52
+ readonly wantsKeyRelease = false;
53
+ readonly #options: LiveVisualizerOptions;
54
+ #phase: LivePhase = "connecting";
55
+ #inputLevel = 0;
56
+ #displayLevel = 0;
57
+ #frame = 0;
58
+ #transcriptRole: LiveTranscript["role"] | undefined;
59
+ #transcriptText = "";
60
+ #cache: RenderCache | undefined;
61
+ #animationInterval: NodeJS.Timeout | undefined;
62
+
63
+ constructor(options: LiveVisualizerOptions) {
64
+ this.#options = options;
65
+ this.#animationInterval = setInterval(() => {
66
+ this.#frame += 1;
67
+ const decayed = this.#displayLevel * 0.84;
68
+ this.#displayLevel = Math.max(this.#inputLevel, decayed < 0.001 ? 0 : decayed);
69
+ this.#changed();
70
+ }, ANIMATION_INTERVAL_MS);
71
+ this.#animationInterval.unref?.();
72
+ }
73
+
74
+ setPhase(phase: LivePhase): void {
75
+ if (this.#phase === phase) return;
76
+ this.#phase = phase;
77
+ this.#changed();
78
+ }
79
+
80
+ setInputLevel(level: number): void {
81
+ const next = Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0;
82
+ if (this.#inputLevel === next) return;
83
+ this.#inputLevel = next;
84
+ if (next > this.#displayLevel) this.#displayLevel = next;
85
+ this.#changed();
86
+ }
87
+
88
+ setTranscript(transcript: LiveTranscript | undefined): void {
89
+ const role = transcript?.role;
90
+ const text = sanitizeTranscript(transcript?.text ?? "");
91
+ if (this.#transcriptRole === role && this.#transcriptText === text) return;
92
+ this.#transcriptRole = role;
93
+ this.#transcriptText = text;
94
+ this.#changed();
95
+ }
96
+
97
+ handleInput(data: string): void {
98
+ if (
99
+ matchesKey(data, Key.escape) ||
100
+ matchesKey(data, Key.ctrl("c")) ||
101
+ matchesKey(data, LIVE_TOGGLE_KEY)
102
+ ) {
103
+ this.#options.onStop();
104
+ } else if (matchesKey(data, Key.space)) {
105
+ this.#options.onToggleMute();
106
+ }
107
+ }
108
+
109
+ invalidate(): void {
110
+ this.#cache = undefined;
111
+ }
112
+
113
+ dispose(): void {
114
+ if (!this.#animationInterval) return;
115
+ clearInterval(this.#animationInterval);
116
+ this.#animationInterval = undefined;
117
+ }
118
+
119
+ render(maxWidth: number): string[] {
120
+ if (maxWidth <= 0) return ["", "", "", "", ""];
121
+ const width = maxWidth;
122
+ if (
123
+ this.#cache &&
124
+ this.#cache.width === width &&
125
+ this.#cache.phase === this.#phase &&
126
+ this.#cache.displayLevel === this.#displayLevel &&
127
+ this.#cache.frame === this.#frame &&
128
+ this.#cache.transcriptRole === this.#transcriptRole &&
129
+ this.#cache.transcriptText === this.#transcriptText
130
+ ) {
131
+ return this.#cache.lines;
132
+ }
133
+
134
+ const lines = this.#renderLines(width);
135
+ this.#cache = {
136
+ width,
137
+ phase: this.#phase,
138
+ displayLevel: this.#displayLevel,
139
+ frame: this.#frame,
140
+ transcriptRole: this.#transcriptRole,
141
+ transcriptText: this.#transcriptText,
142
+ lines,
143
+ };
144
+ return lines;
145
+ }
146
+
147
+ #changed(): void {
148
+ this.invalidate();
149
+ this.#options.requestRender();
150
+ }
151
+
152
+ #renderLines(width: number): string[] {
153
+ if (width === 1) {
154
+ const color = this.#phase === "error" ? "error" : this.#phase === "muted" ? "dim" : "success";
155
+ return [
156
+ this.#options.theme.fg("border", "┐"),
157
+ this.#options.theme.fg(color, "▂"),
158
+ this.#options.theme.fg(color, "▆"),
159
+ this.#options.theme.fg("accent", "…"),
160
+ this.#options.theme.fg("border", "┘"),
161
+ ];
162
+ }
163
+
164
+ const innerWidth = width - 2;
165
+ const theme = this.#options.theme;
166
+ const border = (content: string): string =>
167
+ theme.fg("border", "│") + content + theme.fg("border", "│");
168
+ const top = theme.fg("border", `┌${"─".repeat(innerWidth)}┐`);
169
+ const spectrumColor: ThemeColor =
170
+ this.#phase === "muted" || this.#phase === "connecting"
171
+ ? "dim"
172
+ : this.#phase === "error"
173
+ ? "error"
174
+ : "success";
175
+ const spectrumRows = this.#generateSpectrum(innerWidth, 2).map((row) =>
176
+ border(theme.fg(spectrumColor, row)),
177
+ );
178
+ return [
179
+ top,
180
+ ...spectrumRows,
181
+ this.#renderTranscript(innerWidth, border),
182
+ this.#renderFooter(innerWidth),
183
+ ];
184
+ }
185
+
186
+ #renderTranscript(innerWidth: number, border: (content: string) => string): string {
187
+ const theme = this.#options.theme;
188
+ const label =
189
+ this.#transcriptRole === "assistant"
190
+ ? "live › "
191
+ : this.#transcriptRole === "user"
192
+ ? "you › "
193
+ : "";
194
+ const labelWidth = visibleWidth(label);
195
+ const showLabel = labelWidth < innerWidth;
196
+ const available = Math.max(0, innerWidth - (showLabel ? labelWidth : 0));
197
+ const transcript = truncateFromStart(this.#transcriptText, available);
198
+ const contentWidth = (showLabel ? labelWidth : 0) + visibleWidth(transcript);
199
+ const padding = " ".repeat(Math.max(0, innerWidth - contentWidth));
200
+ const themedLabel = showLabel ? theme.fg("dim", label) : "";
201
+ const transcriptColor: ThemeColor =
202
+ this.#transcriptRole === "assistant" ? "borderAccent" : "accent";
203
+ return border(themedLabel + theme.fg(transcriptColor, transcript) + padding);
204
+ }
205
+
206
+ #renderFooter(innerWidth: number): string {
207
+ const theme = this.#options.theme;
208
+ const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
209
+ const staticIcons: Record<LivePhase, string> = {
210
+ connecting: "○",
211
+ listening: "●",
212
+ working: "○",
213
+ speaking: "»",
214
+ muted: "×",
215
+ error: "!",
216
+ };
217
+ const icon =
218
+ this.#phase === "connecting" || this.#phase === "working"
219
+ ? spinners[this.#frame % spinners.length]!
220
+ : staticIcons[this.#phase];
221
+ const phaseColors: Record<LivePhase, ThemeColor> = {
222
+ connecting: "dim",
223
+ listening: "success",
224
+ working: "warning",
225
+ speaking: "accent",
226
+ muted: "dim",
227
+ error: "error",
228
+ };
229
+ const status = `${icon} ${this.#phase}`;
230
+ const fullLabel = ` ${status} · space mute · esc end `;
231
+ const shortLabel = ` ${status} `;
232
+ const label =
233
+ innerWidth >= visibleWidth(fullLabel) + 1
234
+ ? fullLabel
235
+ : innerWidth >= visibleWidth(shortLabel) + 1
236
+ ? shortLabel
237
+ : "";
238
+ if (!label) return theme.fg("border", `└${"─".repeat(innerWidth)}┘`);
239
+ const clipped = truncateToWidth(label, Math.max(0, innerWidth - 1), "");
240
+ const remaining = Math.max(0, innerWidth - visibleWidth(clipped) - 1);
241
+ return (
242
+ theme.fg("border", "└─") +
243
+ theme.fg(phaseColors[this.#phase], clipped) +
244
+ theme.fg("border", `${"─".repeat(remaining)}┘`)
245
+ );
246
+ }
247
+
248
+ #generateSpectrum(width: number, rows: number): string[] {
249
+ const blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
250
+ const output = Array.from({ length: rows }, () => "");
251
+ const microphoneEnergy = Math.min(1, Math.sqrt(this.#displayLevel * 5));
252
+ const connectingEnergy =
253
+ this.#phase === "connecting" ? 0.06 + 0.03 * Math.sin(this.#frame * 0.35) : 0;
254
+ const energy = this.#phase === "muted" ? 0 : Math.max(microphoneEnergy, connectingEnergy);
255
+ const maxHeight = rows * (blocks.length - 1);
256
+ for (let column = 0; column < width; column += 1) {
257
+ const carrier = 0.5 + 0.5 * Math.sin(this.#frame * 0.43 + column * 0.71);
258
+ const shimmer = 0.5 + 0.5 * Math.sin(this.#frame * 0.19 - column * 1.17);
259
+ const height = Math.round(energy * (0.3 + carrier * 0.5 + shimmer * 0.2) * maxHeight);
260
+ for (let row = 0; row < rows; row += 1) {
261
+ const units = Math.max(0, Math.min(blocks.length - 1, height - (rows - row - 1) * 8));
262
+ output[row] = (output[row] ?? "") + (blocks[units] ?? "");
263
+ }
264
+ }
265
+ return output;
266
+ }
267
+ }
268
+
269
+ export const LIVE_VISUALIZER_TOGGLE_KEY = LIVE_TOGGLE_KEY;
@@ -0,0 +1,21 @@
1
+ export const LIVE_VOICE_OPTIONS = [
2
+ { value: "arbor", label: "Arbor" },
3
+ { value: "breeze", label: "Breeze" },
4
+ { value: "cove", label: "Cove" },
5
+ { value: "ember", label: "Ember" },
6
+ { value: "juniper", label: "Juniper" },
7
+ { value: "maple", label: "Maple" },
8
+ { value: "sol", label: "Sol" },
9
+ { value: "spruce", label: "Spruce" },
10
+ { value: "vale", label: "Vale" },
11
+ ] as const;
12
+
13
+ export const LIVE_VOICE_VALUES = LIVE_VOICE_OPTIONS.map(({ value }) => value);
14
+
15
+ export type LiveVoice = (typeof LIVE_VOICE_OPTIONS)[number]["value"];
16
+
17
+ export const DEFAULT_LIVE_VOICE: LiveVoice = "sol";
18
+
19
+ export function isLiveVoice(value: unknown): value is LiveVoice {
20
+ return typeof value === "string" && (LIVE_VOICE_VALUES as readonly string[]).includes(value);
21
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve } from "node:path";
3
+
4
+ export function expandTildePath(path: string, home = homedir()): string {
5
+ if (path === "~") return home;
6
+ if (path.startsWith("~/") || path.startsWith("~\\")) return join(home, path.slice(2));
7
+ return path;
8
+ }
9
+
10
+ export function piAgentDir(env = process.env, home = homedir()): string {
11
+ const configuredDir = env.PI_CODING_AGENT_DIR?.trim();
12
+ return configuredDir ? expandTildePath(configuredDir, home) : join(home, ".pi", "agent");
13
+ }
14
+
15
+ export function resolveUserPath(path: string, cwd: string, home = homedir()): string {
16
+ return resolve(cwd, expandTildePath(path, home));
17
+ }