@openlines/opl-pi-sht 0.1.14
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/LICENSE +21 -0
- package/README.md +191 -0
- package/configs/opl-browser.json.sample +8 -0
- package/configs/opl-footer.json +48 -0
- package/configs/opl-footer.json.sample +48 -0
- package/configs/opl-input.json +22 -0
- package/configs/opl-input.json.sample +22 -0
- package/configs/opl-modes.json +291 -0
- package/configs/opl-modes.json.sample +285 -0
- package/configs/opl-simplebench.json +8 -0
- package/configs/opl-simplebench.json.sample +8 -0
- package/configs/opl-todo.json +12 -0
- package/configs/opl-todo.json.sample +12 -0
- package/configs/opl-webaccess.json +31 -0
- package/configs/opl-webaccess.json.sample +31 -0
- package/extensions/opl-browser/README.md +83 -0
- package/extensions/opl-browser/browser.ts +185 -0
- package/extensions/opl-browser/config.ts +37 -0
- package/extensions/opl-browser/index.ts +89 -0
- package/extensions/opl-browser/package-lock.json +60 -0
- package/extensions/opl-browser/package.json +10 -0
- package/extensions/opl-ctxtrim/README.md +78 -0
- package/extensions/opl-ctxtrim/index.ts +151 -0
- package/extensions/opl-footer/README.md +193 -0
- package/extensions/opl-footer/config.ts +91 -0
- package/extensions/opl-footer/git-status.ts +170 -0
- package/extensions/opl-footer/icons.ts +49 -0
- package/extensions/opl-footer/index.ts +368 -0
- package/extensions/opl-footer/segments/caveman.ts +25 -0
- package/extensions/opl-footer/segments/chat-mode.ts +24 -0
- package/extensions/opl-footer/segments/context.ts +92 -0
- package/extensions/opl-footer/segments/cost.ts +25 -0
- package/extensions/opl-footer/segments/git.ts +47 -0
- package/extensions/opl-footer/segments/helpers.ts +18 -0
- package/extensions/opl-footer/segments/index.ts +51 -0
- package/extensions/opl-footer/segments/mode-switcher.ts +24 -0
- package/extensions/opl-footer/segments/model.ts +22 -0
- package/extensions/opl-footer/segments/path.ts +34 -0
- package/extensions/opl-footer/segments/pi.ts +11 -0
- package/extensions/opl-footer/segments/plan-mode.ts +24 -0
- package/extensions/opl-footer/segments/separator.ts +9 -0
- package/extensions/opl-footer/segments/session-stats.ts +82 -0
- package/extensions/opl-footer/segments/thinking.ts +51 -0
- package/extensions/opl-footer/segments/tokens.ts +66 -0
- package/extensions/opl-footer/theme.ts +113 -0
- package/extensions/opl-footer/types.ts +172 -0
- package/extensions/opl-init/README.md +23 -0
- package/extensions/opl-init/index.ts +451 -0
- package/extensions/opl-input/README.md +83 -0
- package/extensions/opl-input/config.ts +137 -0
- package/extensions/opl-input/index.ts +284 -0
- package/extensions/opl-input/mode-style.ts +38 -0
- package/extensions/opl-input/utils.ts +378 -0
- package/extensions/opl-modes/README.md +132 -0
- package/extensions/opl-modes/config.ts +473 -0
- package/extensions/opl-modes/index.ts +1081 -0
- package/extensions/opl-modes/menus.ts +60 -0
- package/extensions/opl-modes/state.ts +152 -0
- package/extensions/opl-modes/types.ts +164 -0
- package/extensions/opl-modes/utils.ts +138 -0
- package/extensions/opl-questionnaire/README.md +26 -0
- package/extensions/opl-questionnaire/index.ts +445 -0
- package/extensions/opl-questionnaire/types.ts +55 -0
- package/extensions/opl-simplebench/README.md +242 -0
- package/extensions/opl-simplebench/artifact.ts +41 -0
- package/extensions/opl-simplebench/benchmark.ts +873 -0
- package/extensions/opl-simplebench/coding.ts +282 -0
- package/extensions/opl-simplebench/index.ts +203 -0
- package/extensions/opl-simplebench/llama-server.ts +290 -0
- package/extensions/opl-simplebench/metrics.ts +73 -0
- package/extensions/opl-simplebench/report.ts +46 -0
- package/extensions/opl-simplebench/research.ts +200 -0
- package/extensions/opl-simplebench/scoring.ts +48 -0
- package/extensions/opl-simplebench/tests.ts +39 -0
- package/extensions/opl-simplebench/types.ts +6 -0
- package/extensions/opl-simplebench/util/config.ts +170 -0
- package/extensions/opl-simplebench/util/debug.ts +32 -0
- package/extensions/opl-simplebench/util/format.ts +402 -0
- package/extensions/opl-simplebench/util/providers.ts +841 -0
- package/extensions/opl-todo/README.md +43 -0
- package/extensions/opl-todo/config.ts +71 -0
- package/extensions/opl-todo/index.ts +415 -0
- package/extensions/opl-webaccess/README.md +47 -0
- package/extensions/opl-webaccess/config.ts +55 -0
- package/extensions/opl-webaccess/extract.ts +83 -0
- package/extensions/opl-webaccess/index.ts +232 -0
- package/extensions/opl-webaccess/package-lock.json +345 -0
- package/extensions/opl-webaccess/package.json +18 -0
- package/extensions/opl-webaccess/pdf.ts +8 -0
- package/extensions/opl-webaccess/providers/ddgs.ts +72 -0
- package/extensions/opl-webaccess/providers/exa.ts +74 -0
- package/extensions/opl-webaccess/providers/gemini.ts +60 -0
- package/extensions/opl-webaccess/providers/searxng.ts +86 -0
- package/extensions/opl-webaccess/providers/tavily.ts +60 -0
- package/extensions/opl-webaccess/search.ts +36 -0
- package/extensions/opl-webaccess/storage.ts +51 -0
- package/extensions/opl-webaccess/turndown.d.ts +1 -0
- package/extensions/opl-webaccess/types.ts +26 -0
- package/extensions/opl-webaccess/utils.ts +28 -0
- package/package.json +55 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { CustomEditor, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI, EditorTheme } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
5
|
+
import { CONFIG, COMPANION_PADDING, MIN_WIDTH_FOR_COMPANION } from "./config.js";
|
|
6
|
+
import { resolveModeStyle, type ModeAppearance } from "./mode-style.js";
|
|
7
|
+
import { applyColor, CompanionAnimator } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
10
|
+
const ANSI_RE = /\x1b\[[0-9;]*m|\x1b\[0?m/g;
|
|
11
|
+
|
|
12
|
+
function plainText(line: string): string {
|
|
13
|
+
return line.replace(ANSI_RE, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function activeMode(): { mode: string; appearance?: ModeAppearance } {
|
|
17
|
+
const state = (globalThis as Record<string, unknown>).__agentMode as
|
|
18
|
+
| { mode?: string; appearance?: ModeAppearance }
|
|
19
|
+
| undefined;
|
|
20
|
+
return { mode: state?.mode ?? "off", appearance: state?.appearance };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Component ────────────────────────────────────────────────────────────
|
|
24
|
+
class ChatInput extends CustomEditor {
|
|
25
|
+
private companionColor: (s: string) => string;
|
|
26
|
+
// Full Theme (not EditorTheme): custom-mode colors resolve through theme.fg,
|
|
27
|
+
// which EditorTheme does not implement.
|
|
28
|
+
private uiTheme: Theme;
|
|
29
|
+
private inputTheme: EditorTheme;
|
|
30
|
+
private animator = new CompanionAnimator();
|
|
31
|
+
private companionTimer: ReturnType<typeof setInterval> | null = null;
|
|
32
|
+
|
|
33
|
+
constructor(
|
|
34
|
+
tui: TUI,
|
|
35
|
+
theme: EditorTheme,
|
|
36
|
+
keybindings: KeybindingsManager,
|
|
37
|
+
companionColor: (s: string) => string,
|
|
38
|
+
uiTheme: Theme,
|
|
39
|
+
) {
|
|
40
|
+
super(tui, theme, keybindings, { paddingX: 0 });
|
|
41
|
+
this.companionColor = companionColor;
|
|
42
|
+
this.uiTheme = uiTheme;
|
|
43
|
+
this.inputTheme = theme;
|
|
44
|
+
|
|
45
|
+
// Animate companion even when idle — tick drives state machine
|
|
46
|
+
this.companionTimer = setInterval(() => {
|
|
47
|
+
this.animator.tick(Date.now());
|
|
48
|
+
this.tui.requestRender();
|
|
49
|
+
}, 100);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private isBashMode(): boolean {
|
|
53
|
+
const text = (this as any).getText?.();
|
|
54
|
+
return typeof text === "string" && text.trimStart().startsWith("!");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
render(width: number): string[] {
|
|
58
|
+
const padMultiplier = CONFIG.BOXED_VIEW ? 3 : 1;
|
|
59
|
+
if (width < 5 + CONFIG.BOX_PAD_X * padMultiplier) return super.render(width);
|
|
60
|
+
|
|
61
|
+
const contentWidth = CONFIG.BOXED_VIEW
|
|
62
|
+
? width - 3 - CONFIG.BOX_PAD_X * 3
|
|
63
|
+
: width - 2 * CONFIG.BOX_PAD_X - 1;
|
|
64
|
+
const stock = super.render(contentWidth);
|
|
65
|
+
if (stock.length < 2) return super.render(width);
|
|
66
|
+
|
|
67
|
+
const isBash = this.isBashMode();
|
|
68
|
+
const mode = activeMode();
|
|
69
|
+
const style = resolveModeStyle({ bash: isBash, mode: mode.mode, appearance: mode.appearance });
|
|
70
|
+
const border = (s: string) => applyColor(this.uiTheme, style.borderColor, s);
|
|
71
|
+
const accent = (s: string) => applyColor(this.uiTheme, style.prefixColor, s);
|
|
72
|
+
const prefix = style.prefix;
|
|
73
|
+
|
|
74
|
+
if (CONFIG.BOXED_VIEW) {
|
|
75
|
+
return this.renderBoxed(stock, contentWidth, width, border, accent, prefix);
|
|
76
|
+
}
|
|
77
|
+
return this.renderUnboxed(stock, contentWidth, width, border, accent, prefix);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private buildCompanionLines(width: number): string[] {
|
|
81
|
+
if (!CONFIG.COMPANION_ENABLED || width < MIN_WIDTH_FOR_COMPANION) return [];
|
|
82
|
+
|
|
83
|
+
const state = this.animator.getState();
|
|
84
|
+
const artWidth = Math.max(...state.lines.map(l => visibleWidth(l)), 0);
|
|
85
|
+
const rawPad = width - COMPANION_PADDING - artWidth + state.extraPad;
|
|
86
|
+
// Clamp: never negative, never exceed terminal width
|
|
87
|
+
const pad = Math.max(0, Math.min(rawPad, width - artWidth));
|
|
88
|
+
const spaces = " ".repeat(pad);
|
|
89
|
+
|
|
90
|
+
const lines: string[] = [];
|
|
91
|
+
for (const line of state.lines) {
|
|
92
|
+
lines.push(spaces + this.companionColor(line));
|
|
93
|
+
}
|
|
94
|
+
const topPadding = CONFIG.COMPANION_ENABLED ? CONFIG.COMPANION_TOP_PADDING : 0;
|
|
95
|
+
// Reserve topPadding lines so chat bar doesn't jump — art anchored to bottom
|
|
96
|
+
while (lines.length < topPadding) {
|
|
97
|
+
lines.unshift("");
|
|
98
|
+
}
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private renderBoxed(
|
|
103
|
+
stock: string[],
|
|
104
|
+
contentWidth: number,
|
|
105
|
+
width: number,
|
|
106
|
+
border: (s: string) => string,
|
|
107
|
+
accent: (s: string) => string,
|
|
108
|
+
prefix: string,
|
|
109
|
+
): string[] {
|
|
110
|
+
const innerWidth = width - 2;
|
|
111
|
+
|
|
112
|
+
// Solid border: after stripping ANSI every char is "─"
|
|
113
|
+
const isSolidBorder = (line: string) => plainText(line).replace(/─/g, "").length === 0;
|
|
114
|
+
|
|
115
|
+
// Scroll indicator: starts with "─" and contains ↑/↓ N more
|
|
116
|
+
const getScrollText = (line: string): string | null => {
|
|
117
|
+
const plain = plainText(line);
|
|
118
|
+
if (!plain.startsWith("─")) return null;
|
|
119
|
+
const m = plain.match(/((?:↑|↓)\s*\d+\s*more)/);
|
|
120
|
+
return m ? m[1] : null;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const isBorderLike = (line: string) => isSolidBorder(line) || getScrollText(line) !== null;
|
|
124
|
+
|
|
125
|
+
const firstIdx = stock.findIndex(isBorderLike);
|
|
126
|
+
let lastIdx = -1;
|
|
127
|
+
for (let i = stock.length - 1; i >= 0; i--) {
|
|
128
|
+
if (isBorderLike(stock[i]!)) {
|
|
129
|
+
lastIdx = i;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Build top/bottom box borders, embedding scroll indicator text when present
|
|
135
|
+
const buildTop = (scrollText: string | null): string => {
|
|
136
|
+
if (!scrollText) return border("┌") + border("─".repeat(innerWidth)) + border("┐");
|
|
137
|
+
const mid = `── ${scrollText} `;
|
|
138
|
+
const remaining = Math.max(0, innerWidth - visibleWidth(mid));
|
|
139
|
+
return border("┌") + border(mid) + border("─".repeat(remaining)) + border("┐");
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const buildBottom = (scrollText: string | null): string => {
|
|
143
|
+
if (!scrollText) return border("└") + border("─".repeat(innerWidth)) + border("┘");
|
|
144
|
+
const mid = `── ${scrollText} `;
|
|
145
|
+
const remaining = Math.max(0, innerWidth - visibleWidth(mid));
|
|
146
|
+
return border("└") + border(mid) + border("─".repeat(remaining)) + border("┘");
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const topScrollText = firstIdx !== -1 ? getScrollText(stock[firstIdx]!) : null;
|
|
150
|
+
const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? getScrollText(stock[lastIdx]!) : null;
|
|
151
|
+
|
|
152
|
+
const top = buildTop(topScrollText);
|
|
153
|
+
const bottom = buildBottom(bottomScrollText);
|
|
154
|
+
|
|
155
|
+
// ── companion art ──
|
|
156
|
+
const companionLines = this.buildCompanionLines(width);
|
|
157
|
+
|
|
158
|
+
const leftPad = " ".repeat(CONFIG.BOX_PAD_X);
|
|
159
|
+
const rightPad = leftPad;
|
|
160
|
+
|
|
161
|
+
// ── body lines (between first and last border/indicator) ──
|
|
162
|
+
const body: string[] = [];
|
|
163
|
+
let isFirstBodyLine = true;
|
|
164
|
+
for (let i = 0; i < stock.length; i++) {
|
|
165
|
+
if (i === firstIdx || i === lastIdx) continue;
|
|
166
|
+
if (lastIdx !== -1 && i > lastIdx) continue;
|
|
167
|
+
|
|
168
|
+
const vw = visibleWidth(stock[i]!);
|
|
169
|
+
const pad = vw < contentWidth ? " ".repeat(contentWidth - vw) : "";
|
|
170
|
+
const prefixStr = isFirstBodyLine ? accent(prefix) : " ";
|
|
171
|
+
body.push(border("│") + leftPad + prefixStr + leftPad + stock[i]! + pad + rightPad + border("│"));
|
|
172
|
+
isFirstBodyLine = false;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── menu lines (after last border/indicator) ──
|
|
176
|
+
const menu: string[] = [];
|
|
177
|
+
if (lastIdx !== -1) {
|
|
178
|
+
for (let i = lastIdx + 1; i < stock.length; i++) {
|
|
179
|
+
const vw = visibleWidth(stock[i]!);
|
|
180
|
+
const indent = " ".repeat(CONFIG.EXTRA_MENU_INDENT);
|
|
181
|
+
const pad = vw + CONFIG.EXTRA_MENU_INDENT < width ? " ".repeat(width - vw - CONFIG.EXTRA_MENU_INDENT) : "";
|
|
182
|
+
menu.push(indent + stock[i]! + pad);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const gap = Array.from({ length: CONFIG.MENU_GAP }, () => "");
|
|
187
|
+
return [...companionLines, top, ...body, bottom, ...gap, ...menu];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private renderUnboxed(
|
|
191
|
+
stock: string[],
|
|
192
|
+
contentWidth: number,
|
|
193
|
+
width: number,
|
|
194
|
+
border: (s: string) => string,
|
|
195
|
+
accent: (s: string) => string,
|
|
196
|
+
prefix: string,
|
|
197
|
+
): string[] {
|
|
198
|
+
// Solid border: after stripping ANSI every char is "─"
|
|
199
|
+
const isSolidBorder = (line: string) => plainText(line).replace(/─/g, "").length === 0;
|
|
200
|
+
|
|
201
|
+
// Scroll indicator: starts with "─" and contains ↑/↓ N more
|
|
202
|
+
const getScrollText = (line: string): string | null => {
|
|
203
|
+
const plain = plainText(line);
|
|
204
|
+
if (!plain.startsWith("─")) return null;
|
|
205
|
+
const m = plain.match(/((?:↑|↓)\s*\d+\s*more)/);
|
|
206
|
+
return m ? m[1] : null;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const isBorderLike = (line: string) => isSolidBorder(line) || getScrollText(line) !== null;
|
|
210
|
+
|
|
211
|
+
const firstIdx = stock.findIndex(isBorderLike);
|
|
212
|
+
let lastIdx = -1;
|
|
213
|
+
for (let i = stock.length - 1; i >= 0; i--) {
|
|
214
|
+
if (isBorderLike(stock[i]!)) {
|
|
215
|
+
lastIdx = i;
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Build top/bottom horizontal borders only (no corners, no sides)
|
|
221
|
+
const buildTop = (scrollText: string | null): string => {
|
|
222
|
+
if (!scrollText) return border("─".repeat(width));
|
|
223
|
+
const mid = `── ${scrollText} `;
|
|
224
|
+
const remaining = Math.max(0, width - visibleWidth(mid));
|
|
225
|
+
return border(mid) + border("─".repeat(remaining));
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const buildBottom = (scrollText: string | null): string => {
|
|
229
|
+
if (!scrollText) return border("─".repeat(width));
|
|
230
|
+
const mid = `── ${scrollText} `;
|
|
231
|
+
const remaining = Math.max(0, width - visibleWidth(mid));
|
|
232
|
+
return border(mid) + border("─".repeat(remaining));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const topScrollText = firstIdx !== -1 ? getScrollText(stock[firstIdx]!) : null;
|
|
236
|
+
const bottomScrollText = lastIdx !== -1 && lastIdx !== firstIdx ? getScrollText(stock[lastIdx]!) : null;
|
|
237
|
+
|
|
238
|
+
const top = buildTop(topScrollText);
|
|
239
|
+
const bottom = buildBottom(bottomScrollText);
|
|
240
|
+
|
|
241
|
+
// ── companion art ──
|
|
242
|
+
const companionLines = this.buildCompanionLines(width);
|
|
243
|
+
|
|
244
|
+
const leftPad = " ".repeat(CONFIG.BOX_PAD_X);
|
|
245
|
+
|
|
246
|
+
// ── body lines ──
|
|
247
|
+
const body: string[] = [];
|
|
248
|
+
let isFirstBodyLine = true;
|
|
249
|
+
for (let i = 0; i < stock.length; i++) {
|
|
250
|
+
if (i === firstIdx || i === lastIdx) continue;
|
|
251
|
+
if (lastIdx !== -1 && i > lastIdx) continue;
|
|
252
|
+
|
|
253
|
+
const vw = visibleWidth(stock[i]!);
|
|
254
|
+
const pad = vw < contentWidth ? " ".repeat(contentWidth - vw) : "";
|
|
255
|
+
const prefixStr = isFirstBodyLine ? accent(prefix) : " ";
|
|
256
|
+
body.push(leftPad + prefixStr + leftPad + stock[i]! + pad);
|
|
257
|
+
isFirstBodyLine = false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── menu lines ──
|
|
261
|
+
const menu: string[] = [];
|
|
262
|
+
if (lastIdx !== -1) {
|
|
263
|
+
for (let i = lastIdx + 1; i < stock.length; i++) {
|
|
264
|
+
const vw = visibleWidth(stock[i]!);
|
|
265
|
+
const indent = " ".repeat(CONFIG.EXTRA_MENU_INDENT);
|
|
266
|
+
const pad = vw + CONFIG.EXTRA_MENU_INDENT < width ? " ".repeat(width - vw - CONFIG.EXTRA_MENU_INDENT) : "";
|
|
267
|
+
menu.push(indent + stock[i]! + pad);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const gap = Array.from({ length: CONFIG.MENU_GAP }, () => "");
|
|
272
|
+
return [...companionLines, top, ...body, bottom, ...gap, ...menu];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ─── Extension entry ──────────────────────────────────────────────────────
|
|
277
|
+
export default function (pi: ExtensionAPI) {
|
|
278
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
279
|
+
ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, kb: KeybindingsManager) => {
|
|
280
|
+
const companionColorFn = (s: string) => applyColor(ctx.ui.theme, CONFIG.COMPANION_COLOR, s);
|
|
281
|
+
return new ChatInput(tui, theme, kb, companionColorFn, ctx.ui.theme);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Pure mode-to-style resolution for opl-input. No external imports so it can
|
|
2
|
+
// be unit-tested directly (tests/opl-input-style.test.mjs).
|
|
3
|
+
|
|
4
|
+
export interface ModeAppearance {
|
|
5
|
+
prefix?: string;
|
|
6
|
+
prefixColor?: string;
|
|
7
|
+
borderColor?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ModeState {
|
|
11
|
+
bash: boolean;
|
|
12
|
+
mode: string;
|
|
13
|
+
appearance?: ModeAppearance;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ResolvedModeStyle {
|
|
17
|
+
borderColor: string;
|
|
18
|
+
prefixColor: string;
|
|
19
|
+
prefix: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MODE_DEFAULTS: Record<string, Required<ModeAppearance>> = {
|
|
23
|
+
off: { prefix: "❯", prefixColor: "accent", borderColor: "border" },
|
|
24
|
+
chat: { prefix: "»", prefixColor: "chatModeBorder", borderColor: "chatModeBorder" },
|
|
25
|
+
plan: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
|
|
26
|
+
execute: { prefix: "⏸", prefixColor: "customMessageLabel", borderColor: "customMessageLabel" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Precedence: bash > active mode appearance > hardcoded mode fallback. */
|
|
30
|
+
export function resolveModeStyle(state: ModeState): ResolvedModeStyle {
|
|
31
|
+
const fallback = MODE_DEFAULTS[state.mode] ?? MODE_DEFAULTS.off;
|
|
32
|
+
if (state.bash) return { borderColor: "bashMode", prefixColor: "bashMode", prefix: fallback.prefix };
|
|
33
|
+
return {
|
|
34
|
+
borderColor: state.appearance?.borderColor ?? fallback.borderColor,
|
|
35
|
+
prefixColor: state.appearance?.prefixColor ?? fallback.prefixColor,
|
|
36
|
+
prefix: state.appearance?.prefix ?? fallback.prefix,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
COMPANION_ARTS,
|
|
4
|
+
BLINK_ART,
|
|
5
|
+
DIP_INTERVAL_MS,
|
|
6
|
+
RISE_INTERVAL_MS,
|
|
7
|
+
EARS_MIN_DURATION_MS,
|
|
8
|
+
EARS_MAX_DURATION_MS,
|
|
9
|
+
FULL_MIN_DURATION_MS,
|
|
10
|
+
FULL_MAX_DURATION_MS,
|
|
11
|
+
NONE_MIN_DURATION_MS,
|
|
12
|
+
NONE_MAX_DURATION_MS,
|
|
13
|
+
FACE_MIN_DURATION_MS,
|
|
14
|
+
FACE_MAX_DURATION_MS,
|
|
15
|
+
EXPR_MIN_DURATION_MS,
|
|
16
|
+
EXPR_MAX_DURATION_MS,
|
|
17
|
+
STARE_MIN_DURATION_MS,
|
|
18
|
+
STARE_MAX_DURATION_MS,
|
|
19
|
+
STARE_CHANCE,
|
|
20
|
+
BLINK_MIN_DURATION_MS,
|
|
21
|
+
BLINK_MAX_DURATION_MS,
|
|
22
|
+
EXPR_BLINK_CHANCE,
|
|
23
|
+
EXPR_DOUBLE_BLINK_CHANCE,
|
|
24
|
+
DOUBLE_BLINK_GAP_MIN_MS,
|
|
25
|
+
DOUBLE_BLINK_GAP_MAX_MS,
|
|
26
|
+
WOBBLE_RANGE,
|
|
27
|
+
WOBBLE_MIN_INTERVAL_MS,
|
|
28
|
+
WOBBLE_MAX_INTERVAL_MS,
|
|
29
|
+
DIR_STEPS_MIN,
|
|
30
|
+
DIR_STEPS_MAX,
|
|
31
|
+
EDGE_BIAS_STRENGTH,
|
|
32
|
+
EDGE_PAUSE_MIN_MS,
|
|
33
|
+
EDGE_PAUSE_MAX_MS,
|
|
34
|
+
FACE_DRIFT_RANGE,
|
|
35
|
+
FACE_DRIFT_MIN_INTERVAL_MS,
|
|
36
|
+
FACE_DRIFT_MAX_INTERVAL_MS,
|
|
37
|
+
EARS_TO_NONE_CHANCE,
|
|
38
|
+
EARS_TO_FULL_CHANCE,
|
|
39
|
+
FULL_TO_EARS_CHANCE,
|
|
40
|
+
FULL_TO_NONE_CHANCE,
|
|
41
|
+
SLOW_TRANSITION_CHANCE,
|
|
42
|
+
SLOW_TRANSITION_MULT_MIN,
|
|
43
|
+
SLOW_TRANSITION_MULT_MAX,
|
|
44
|
+
} from "./config.js";
|
|
45
|
+
|
|
46
|
+
function isHexColor(color: string): boolean {
|
|
47
|
+
return color.startsWith("#");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hexToAnsi(hex: string): string {
|
|
51
|
+
const h = hex.replace("#", "");
|
|
52
|
+
if (!/^[0-9a-fA-F]{6}$/.test(h)) return "";
|
|
53
|
+
const r = parseInt(h.slice(0, 2), 16);
|
|
54
|
+
const g = parseInt(h.slice(2, 4), 16);
|
|
55
|
+
const b = parseInt(h.slice(4, 6), 16);
|
|
56
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function applyColor(theme: Theme, color: string, text: string): string {
|
|
60
|
+
if (isHexColor(color)) {
|
|
61
|
+
return `${hexToAnsi(color)}${text}\x1b[0m`;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
return theme.fg(color as ThemeColor, text);
|
|
65
|
+
} catch {
|
|
66
|
+
// Color not defined in current theme — fall back to 'border'
|
|
67
|
+
try {
|
|
68
|
+
return theme.fg("border" as ThemeColor, text);
|
|
69
|
+
} catch {
|
|
70
|
+
return text; // no color at all rather than crash
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Companion animator ───────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export interface CompanionState {
|
|
78
|
+
lines: string[];
|
|
79
|
+
extraPad: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
type Phase = "face" | "ears" | "full" | "none";
|
|
83
|
+
|
|
84
|
+
const TICK_MS = 100; // matches setInterval in ChatInput
|
|
85
|
+
const R = (min: number, max: number) => min + Math.random() * (max - min);
|
|
86
|
+
|
|
87
|
+
export class CompanionAnimator {
|
|
88
|
+
private phase: Phase = "face";
|
|
89
|
+
private phaseEntered = 0;
|
|
90
|
+
private phaseDuration = 0;
|
|
91
|
+
|
|
92
|
+
private exprIdx = 0;
|
|
93
|
+
private lastExprChange = 0;
|
|
94
|
+
private exprDuration = 0;
|
|
95
|
+
private isBlinking = false;
|
|
96
|
+
private blinkUntil = 0;
|
|
97
|
+
|
|
98
|
+
private doubleBlinkPending = false;
|
|
99
|
+
private doubleBlinkGap = false;
|
|
100
|
+
private gapUntil = 0;
|
|
101
|
+
|
|
102
|
+
private offset = 0;
|
|
103
|
+
private lastOffsetChange = 0;
|
|
104
|
+
private wobbleInterval = 0;
|
|
105
|
+
|
|
106
|
+
private dirDelta = 0;
|
|
107
|
+
private dirSteps = 0;
|
|
108
|
+
|
|
109
|
+
private edgePauseUntil = 0;
|
|
110
|
+
|
|
111
|
+
private lastFaceDrift = 0;
|
|
112
|
+
private faceDriftInterval = 0;
|
|
113
|
+
|
|
114
|
+
private transitionFromLines = 2;
|
|
115
|
+
private transitionRemaining = 0;
|
|
116
|
+
private transitionTotal = 0;
|
|
117
|
+
|
|
118
|
+
// ── tick ──────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
tick(now: number): void {
|
|
121
|
+
if (this.phaseEntered === 0) {
|
|
122
|
+
this.phaseEntered = now;
|
|
123
|
+
this.phaseDuration = R(FACE_MIN_DURATION_MS, FACE_MAX_DURATION_MS);
|
|
124
|
+
this.exprIdx = this.pickNextExpr();
|
|
125
|
+
this.lastExprChange = now;
|
|
126
|
+
this.exprDuration = this.randExprDuration();
|
|
127
|
+
this.lastFaceDrift = now;
|
|
128
|
+
this.faceDriftInterval = R(FACE_DRIFT_MIN_INTERVAL_MS, FACE_DRIFT_MAX_INTERVAL_MS);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (this.transitionRemaining > 0) this.transitionRemaining--;
|
|
133
|
+
|
|
134
|
+
const elapsed = now - this.phaseEntered;
|
|
135
|
+
|
|
136
|
+
if (this.phase === "face" || this.phase === "full") {
|
|
137
|
+
this.tickExpression(now);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (this.phase) {
|
|
141
|
+
case "face": this.tickFace(now, elapsed); break;
|
|
142
|
+
case "ears": this.tickEars(now, elapsed); break;
|
|
143
|
+
case "full": this.tickFull(now, elapsed); break;
|
|
144
|
+
case "none": this.tickNone(elapsed); break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── expression cycling ────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
private tickExpression(now: number): void {
|
|
151
|
+
if (this.doubleBlinkGap && now >= this.gapUntil) {
|
|
152
|
+
this.doubleBlinkGap = false;
|
|
153
|
+
this.isBlinking = true;
|
|
154
|
+
this.blinkUntil = now + R(BLINK_MIN_DURATION_MS, BLINK_MAX_DURATION_MS);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (this.isBlinking && now >= this.blinkUntil) {
|
|
159
|
+
this.isBlinking = false;
|
|
160
|
+
if (this.doubleBlinkPending) {
|
|
161
|
+
this.doubleBlinkPending = false;
|
|
162
|
+
this.doubleBlinkGap = true;
|
|
163
|
+
this.gapUntil = now + R(DOUBLE_BLINK_GAP_MIN_MS, DOUBLE_BLINK_GAP_MAX_MS);
|
|
164
|
+
} else {
|
|
165
|
+
this.exprIdx = this.pickNextExpr();
|
|
166
|
+
this.exprDuration = this.randExprDuration();
|
|
167
|
+
this.lastExprChange = now;
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!this.isBlinking && !this.doubleBlinkGap && now - this.lastExprChange >= this.exprDuration) {
|
|
173
|
+
const roll = Math.random();
|
|
174
|
+
this.lastExprChange = now;
|
|
175
|
+
if (roll < EXPR_BLINK_CHANCE) {
|
|
176
|
+
this.isBlinking = true;
|
|
177
|
+
this.blinkUntil = now + R(BLINK_MIN_DURATION_MS, BLINK_MAX_DURATION_MS);
|
|
178
|
+
} else if (roll < EXPR_BLINK_CHANCE + EXPR_DOUBLE_BLINK_CHANCE) {
|
|
179
|
+
this.isBlinking = true;
|
|
180
|
+
this.blinkUntil = now + R(BLINK_MIN_DURATION_MS, BLINK_MAX_DURATION_MS);
|
|
181
|
+
this.doubleBlinkPending = true;
|
|
182
|
+
} else {
|
|
183
|
+
this.exprIdx = this.pickNextExpr();
|
|
184
|
+
this.exprDuration = this.randExprDuration();
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── face ──────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
private tickFace(now: number, elapsed: number): void {
|
|
192
|
+
if (now - this.lastFaceDrift >= this.faceDriftInterval) {
|
|
193
|
+
const delta = Math.random() < 0.5 ? -1 : 1;
|
|
194
|
+
this.offset = Math.max(-FACE_DRIFT_RANGE, Math.min(FACE_DRIFT_RANGE, this.offset + delta));
|
|
195
|
+
this.lastFaceDrift = now;
|
|
196
|
+
this.faceDriftInterval = R(FACE_DRIFT_MIN_INTERVAL_MS, FACE_DRIFT_MAX_INTERVAL_MS);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (elapsed < this.phaseDuration) return;
|
|
200
|
+
const dipProb = TICK_MS / DIP_INTERVAL_MS;
|
|
201
|
+
const riseProb = TICK_MS / RISE_INTERVAL_MS;
|
|
202
|
+
const roll = Math.random();
|
|
203
|
+
|
|
204
|
+
if (roll < dipProb) {
|
|
205
|
+
this.enterEars(now);
|
|
206
|
+
} else if (roll < dipProb + riseProb) {
|
|
207
|
+
this.enterFull(now);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── ears ──────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
private tickEars(now: number, elapsed: number): void {
|
|
214
|
+
if (now < this.edgePauseUntil) {
|
|
215
|
+
if (elapsed >= this.phaseDuration) this.finishEars(now);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (now - this.lastOffsetChange >= this.wobbleInterval) {
|
|
220
|
+
if (this.dirSteps <= 0) this.startRun();
|
|
221
|
+
|
|
222
|
+
const next = this.offset + this.dirDelta;
|
|
223
|
+
if (next > WOBBLE_RANGE || next < -WOBBLE_RANGE) {
|
|
224
|
+
this.offset = this.dirDelta > 0 ? WOBBLE_RANGE : -WOBBLE_RANGE;
|
|
225
|
+
this.edgePauseUntil = now + R(EDGE_PAUSE_MIN_MS, EDGE_PAUSE_MAX_MS);
|
|
226
|
+
this.dirSteps = 0;
|
|
227
|
+
} else {
|
|
228
|
+
this.offset = next;
|
|
229
|
+
this.dirSteps--;
|
|
230
|
+
}
|
|
231
|
+
this.lastOffsetChange = now;
|
|
232
|
+
this.wobbleInterval = R(WOBBLE_MIN_INTERVAL_MS, WOBBLE_MAX_INTERVAL_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (elapsed >= this.phaseDuration) this.finishEars(now);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private startRun(): void {
|
|
239
|
+
if (this.offset >= WOBBLE_RANGE) {
|
|
240
|
+
this.dirDelta = -1;
|
|
241
|
+
} else if (this.offset <= -WOBBLE_RANGE) {
|
|
242
|
+
this.dirDelta = 1;
|
|
243
|
+
} else {
|
|
244
|
+
const edgeRatio = Math.abs(this.offset) / WOBBLE_RANGE;
|
|
245
|
+
const awayChance = 0.5 + edgeRatio * EDGE_BIAS_STRENGTH;
|
|
246
|
+
if (Math.random() < awayChance) {
|
|
247
|
+
this.dirDelta = this.offset > 0 ? -1 : 1;
|
|
248
|
+
} else {
|
|
249
|
+
this.dirDelta = this.offset > 0 ? 1 : -1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
this.dirSteps = DIR_STEPS_MIN + Math.floor(Math.random() * (DIR_STEPS_MAX - DIR_STEPS_MIN + 1));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private finishEars(now: number): void {
|
|
256
|
+
const roll = Math.random();
|
|
257
|
+
if (roll < EARS_TO_NONE_CHANCE) {
|
|
258
|
+
this.enterNone(now);
|
|
259
|
+
} else if (roll < EARS_TO_NONE_CHANCE + EARS_TO_FULL_CHANCE) {
|
|
260
|
+
this.enterFull(now);
|
|
261
|
+
} else {
|
|
262
|
+
this.enterFace(now);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── full ──────────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
private tickFull(now: number, elapsed: number): void {
|
|
269
|
+
if (elapsed < this.phaseDuration) return;
|
|
270
|
+
const roll = Math.random();
|
|
271
|
+
if (roll < FULL_TO_EARS_CHANCE) {
|
|
272
|
+
this.enterEars(now);
|
|
273
|
+
} else if (roll < FULL_TO_EARS_CHANCE + FULL_TO_NONE_CHANCE) {
|
|
274
|
+
this.enterNone(now);
|
|
275
|
+
} else {
|
|
276
|
+
this.enterFace(now);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── none (hidden) ─────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
private tickNone(elapsed: number): void {
|
|
283
|
+
if (elapsed >= this.phaseDuration) this.enterFace(Date.now());
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ── transitions ───────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
private enterEars(now: number): void {
|
|
289
|
+
this.switchPhase("ears", now);
|
|
290
|
+
this.phaseDuration = R(EARS_MIN_DURATION_MS, EARS_MAX_DURATION_MS);
|
|
291
|
+
this.lastOffsetChange = now;
|
|
292
|
+
this.wobbleInterval = R(WOBBLE_MIN_INTERVAL_MS, WOBBLE_MAX_INTERVAL_MS);
|
|
293
|
+
this.dirSteps = 0;
|
|
294
|
+
this.edgePauseUntil = 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private enterFull(now: number): void {
|
|
298
|
+
this.switchPhase("full", now);
|
|
299
|
+
this.phaseDuration = R(FULL_MIN_DURATION_MS, FULL_MAX_DURATION_MS);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private enterFace(now: number): void {
|
|
303
|
+
this.switchPhase("face", now);
|
|
304
|
+
this.phaseDuration = R(FACE_MIN_DURATION_MS, FACE_MAX_DURATION_MS);
|
|
305
|
+
this.lastFaceDrift = now;
|
|
306
|
+
this.faceDriftInterval = R(FACE_DRIFT_MIN_INTERVAL_MS, FACE_DRIFT_MAX_INTERVAL_MS);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private enterNone(now: number): void {
|
|
310
|
+
this.switchPhase("none", now);
|
|
311
|
+
this.phaseDuration = R(NONE_MIN_DURATION_MS, NONE_MAX_DURATION_MS);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private switchPhase(newPhase: Phase, now: number): void {
|
|
315
|
+
this.transitionFromLines = this.visibleLineCount();
|
|
316
|
+
this.phase = newPhase;
|
|
317
|
+
this.phaseEntered = now;
|
|
318
|
+
const toLines = this.visibleLineCount();
|
|
319
|
+
const lineDiff = Math.abs(toLines - this.transitionFromLines);
|
|
320
|
+
const slow = lineDiff > 0 && Math.random() < SLOW_TRANSITION_CHANCE;
|
|
321
|
+
const mult = slow
|
|
322
|
+
? SLOW_TRANSITION_MULT_MIN + Math.floor(Math.random() * (SLOW_TRANSITION_MULT_MAX - SLOW_TRANSITION_MULT_MIN + 1))
|
|
323
|
+
: 1;
|
|
324
|
+
this.transitionRemaining = lineDiff * mult;
|
|
325
|
+
this.transitionTotal = this.transitionRemaining;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private visibleLineCount(): number {
|
|
329
|
+
switch (this.phase) {
|
|
330
|
+
case "none": return 0;
|
|
331
|
+
case "ears": return 1;
|
|
332
|
+
case "face": return 2;
|
|
333
|
+
case "full": return 3;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ── randomisers ───────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
private pickNextExpr(): number {
|
|
340
|
+
if (COMPANION_ARTS.length <= 1) return 0;
|
|
341
|
+
let next: number;
|
|
342
|
+
do {
|
|
343
|
+
next = Math.floor(Math.random() * COMPANION_ARTS.length);
|
|
344
|
+
} while (next === this.exprIdx);
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private randExprDuration(): number {
|
|
349
|
+
if (Math.random() < STARE_CHANCE) {
|
|
350
|
+
return R(STARE_MIN_DURATION_MS, STARE_MAX_DURATION_MS);
|
|
351
|
+
}
|
|
352
|
+
return R(EXPR_MIN_DURATION_MS, EXPR_MAX_DURATION_MS);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ── render state ──────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
getState(): CompanionState {
|
|
358
|
+
const art = COMPANION_ARTS[this.exprIdx]!;
|
|
359
|
+
const targetLines = this.visibleLineCount();
|
|
360
|
+
|
|
361
|
+
let lineCount: number;
|
|
362
|
+
if (this.transitionRemaining > 0 && this.transitionTotal > 0) {
|
|
363
|
+
const progress = 1 - this.transitionRemaining / this.transitionTotal;
|
|
364
|
+
lineCount = Math.round(this.transitionFromLines + progress * (targetLines - this.transitionFromLines));
|
|
365
|
+
} else {
|
|
366
|
+
lineCount = targetLines;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const blink = this.isBlinking && !this.doubleBlinkGap
|
|
370
|
+
&& (this.phase === "face" || this.phase === "full");
|
|
371
|
+
const lines: string[] = [];
|
|
372
|
+
if (lineCount >= 1) lines.push(art[0]);
|
|
373
|
+
if (lineCount >= 2) lines.push(blink ? BLINK_ART[1] : art[1]);
|
|
374
|
+
if (lineCount >= 3) lines.push(blink ? BLINK_ART[2] : art[2]);
|
|
375
|
+
|
|
376
|
+
return { lines, extraPad: this.offset };
|
|
377
|
+
}
|
|
378
|
+
}
|