@pi-archimedes/core 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.
- package/package.json +33 -0
- package/src/bus.ts +77 -0
- package/src/chrome.ts +91 -0
- package/src/color.ts +195 -0
- package/src/config.ts +59 -0
- package/src/editor/index.ts +199 -0
- package/src/index.ts +157 -0
- package/src/message/index.ts +138 -0
- package/src/startup/capture.ts +30 -0
- package/src/startup/index.ts +277 -0
- package/src/startup/logo.ts +85 -0
- package/src/startup/sections.ts +266 -0
- package/src/startup/version.ts +26 -0
- package/src/text.ts +42 -0
- package/src/thinking/patch.ts +150 -0
- package/src/thinking/theme.ts +132 -0
- package/src/thinking/transform.ts +21 -0
- package/src/thinking/unindent.ts +74 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { TUI, EditorTheme, Component, type SettingItem } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import { HephaestusEditor } from "./editor/index.js";
|
|
6
|
+
import { patchUserMessage, resetInstanceCount } from "./message/index.js";
|
|
7
|
+
import { renderHeader, patchStartupListing, type ListingRef } from "./startup/index.js";
|
|
8
|
+
import { patchConsoleLog } from "./startup/capture.js";
|
|
9
|
+
import { patchThinkingRenderer } from "./thinking/patch.js";
|
|
10
|
+
import { transformThinkingContent } from "./thinking/transform.js";
|
|
11
|
+
import { loadCoreConfig, saveCoreConfig, DEFAULT_CORE_CONFIG, ANIMATION_STYLES, type CoreConfig } from "./config.js";
|
|
12
|
+
import { initBus } from "./bus.js";
|
|
13
|
+
|
|
14
|
+
// ── Settings items ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function getCoreSettingsItems(config: CoreConfig): SettingItem[] {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
id: "mutedTheme",
|
|
20
|
+
label: "Muted Theme",
|
|
21
|
+
description: "Use muted colors for thinking blocks",
|
|
22
|
+
currentValue: config.mutedTheme ? "On" : "Off",
|
|
23
|
+
values: ["On", "Off"],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "codeUnindent",
|
|
27
|
+
label: "Code Unindent",
|
|
28
|
+
description: "Remove 2-space indent from code blocks",
|
|
29
|
+
currentValue: config.codeUnindent ? "On" : "Off",
|
|
30
|
+
values: ["On", "Off"],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "labelText",
|
|
34
|
+
label: "Label Text",
|
|
35
|
+
description: "Text shown before thinking blocks",
|
|
36
|
+
currentValue: config.labelText,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "labelColor",
|
|
40
|
+
label: "Label Color",
|
|
41
|
+
description: "RGB color for thinking label (e.g. 255,215,0)",
|
|
42
|
+
currentValue: config.labelColor,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "animationStyle",
|
|
46
|
+
label: "Logo Animation",
|
|
47
|
+
description: "Splashscreen logo reveal style",
|
|
48
|
+
currentValue: config.animationStyle,
|
|
49
|
+
values: [...ANIMATION_STYLES],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Core registration ─────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
// Module-level state for session lifecycle (shared between session_start and session_shutdown)
|
|
57
|
+
let coreRef: ListingRef | undefined;
|
|
58
|
+
let coreResponseTimes: number[] | undefined;
|
|
59
|
+
let coreCtx: ExtensionContext | undefined;
|
|
60
|
+
|
|
61
|
+
export function registerCore(pi: ExtensionAPI): void {
|
|
62
|
+
// Patch console.log for model scope capture
|
|
63
|
+
patchConsoleLog();
|
|
64
|
+
|
|
65
|
+
// session_shutdown handler (top-level to prevent accumulation on /reload)
|
|
66
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
67
|
+
// Mark listing as settled
|
|
68
|
+
if (coreRef) { coreRef.settled = true; }
|
|
69
|
+
const g: Record<string | symbol, unknown> = globalThis as unknown as typeof global & Record<string | symbol, unknown>;
|
|
70
|
+
const listingRef = g["listingRef"] as ListingRef | undefined;
|
|
71
|
+
if (listingRef) { listingRef.settled = true; }
|
|
72
|
+
|
|
73
|
+
// Clear response times
|
|
74
|
+
if (coreResponseTimes) { coreResponseTimes.length = 0; }
|
|
75
|
+
|
|
76
|
+
// Reset instance count
|
|
77
|
+
resetInstanceCount();
|
|
78
|
+
|
|
79
|
+
// Clear editor component override
|
|
80
|
+
if (coreCtx) { coreCtx.ui.setEditorComponent(undefined); }
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// session_start handler
|
|
84
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
85
|
+
// Initialize bus (flushes queued events)
|
|
86
|
+
initBus();
|
|
87
|
+
|
|
88
|
+
// Save context for shutdown cleanup
|
|
89
|
+
coreCtx = ctx;
|
|
90
|
+
|
|
91
|
+
// Set animated header
|
|
92
|
+
coreRef = {
|
|
93
|
+
sections: [],
|
|
94
|
+
frame: 0,
|
|
95
|
+
revealed: false,
|
|
96
|
+
revealedAt: 0,
|
|
97
|
+
scaffoldAt: 0,
|
|
98
|
+
settled: false,
|
|
99
|
+
};
|
|
100
|
+
const ref = coreRef;
|
|
101
|
+
const headerFactory = (tui: TUI, theme: Theme): Component & { dispose?(): void } => {
|
|
102
|
+
const comp: Component & { dispose?(): void } = {
|
|
103
|
+
invalidate(): void { /* no-op */ },
|
|
104
|
+
render(width: number): string[] {
|
|
105
|
+
return renderHeader(theme, ref, width, tui.terminal.rows - 3);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
patchStartupListing(tui, theme, ref);
|
|
109
|
+
return comp;
|
|
110
|
+
};
|
|
111
|
+
ctx.ui.setHeader(headerFactory);
|
|
112
|
+
|
|
113
|
+
// Shared response times array (used by both patchUserMessage and message_end)
|
|
114
|
+
coreResponseTimes = [];
|
|
115
|
+
const responseTimes = coreResponseTimes;
|
|
116
|
+
|
|
117
|
+
// Set editor component
|
|
118
|
+
ctx.ui.setEditorComponent((tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager) => {
|
|
119
|
+
const theme = ctx.ui.theme;
|
|
120
|
+
return new HephaestusEditor(tui, editorTheme, keybindings, {
|
|
121
|
+
getTheme: () => theme,
|
|
122
|
+
isIdle: () => ctx.isIdle(),
|
|
123
|
+
shutdown: () => ctx.shutdown(),
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Patch thinking renderer
|
|
128
|
+
patchThinkingRenderer(() => ctx.ui.theme);
|
|
129
|
+
|
|
130
|
+
// Patch user message response time
|
|
131
|
+
patchUserMessage(() => ctx.ui.theme, responseTimes);
|
|
132
|
+
|
|
133
|
+
// Load config for thinking transformation
|
|
134
|
+
const config = loadCoreConfig();
|
|
135
|
+
|
|
136
|
+
// Register events
|
|
137
|
+
pi.on("message_end", (event, _ctx) => {
|
|
138
|
+
// Transform thinking content (unindent code blocks if enabled)
|
|
139
|
+
if (config.codeUnindent) {
|
|
140
|
+
transformThinkingContent(event.message as any);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Track response time from the raw message
|
|
144
|
+
const rawMsg = event.message as any;
|
|
145
|
+
if (rawMsg.duration) {
|
|
146
|
+
const idx = rawMsg.instanceIndex ?? responseTimes.length;
|
|
147
|
+
responseTimes[idx] = rawMsg.duration;
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Default export (for standalone pi.extensions loading) ─────────────────
|
|
154
|
+
|
|
155
|
+
export default function (pi: ExtensionAPI): void {
|
|
156
|
+
registerCore(pi);
|
|
157
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { Theme, UserMessageComponent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { RESET, resolvePalette, setThemeBg } from "../chrome.js";
|
|
3
|
+
|
|
4
|
+
type UserMsgCtor = typeof UserMessageComponent & { [PATCHED]?: boolean };
|
|
5
|
+
|
|
6
|
+
// ── Constants ──────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const PATCHED = Symbol.for("splashscreen:userMsgPatched");
|
|
9
|
+
// Match OSC133 B (zone end) or C (zone final). v0.67 moved these from line
|
|
10
|
+
// tail to line head — we strip from wherever they sit and re-emit at the end.
|
|
11
|
+
const OSC133_RE = /\x1b\]133;[BC]\x07/g;
|
|
12
|
+
const MSG_PADDING_X = 3;
|
|
13
|
+
const TIME_COL = 9;
|
|
14
|
+
|
|
15
|
+
// ── Instance tracking ──────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const instanceIndex = new WeakMap<object, number>();
|
|
18
|
+
let instanceCount = 0;
|
|
19
|
+
|
|
20
|
+
export function resetInstanceCount(): void {
|
|
21
|
+
instanceCount = 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatTime(ms: number): string {
|
|
25
|
+
if (ms < 1000) return `${ms}ms`;
|
|
26
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
27
|
+
const m = Math.floor(ms / 60000);
|
|
28
|
+
const s = Math.round((ms % 60000) / 1000);
|
|
29
|
+
return `${m}m ${s}s`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ── Patch ──────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function patchUserMessage(
|
|
35
|
+
getTheme: () => Theme,
|
|
36
|
+
responseTimes: number[],
|
|
37
|
+
): void {
|
|
38
|
+
try {
|
|
39
|
+
let lastBg = "";
|
|
40
|
+
const theme = getTheme();
|
|
41
|
+
const p = resolvePalette(theme);
|
|
42
|
+
lastBg = p.panelBg;
|
|
43
|
+
setThemeBg(theme, "userMessageBg", lastBg);
|
|
44
|
+
|
|
45
|
+
import("@earendil-works/pi-coding-agent").then(
|
|
46
|
+
({ UserMessageComponent }: { UserMessageComponent: UserMsgCtor }) => {
|
|
47
|
+
if (UserMessageComponent[PATCHED]) return;
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
typeof UserMessageComponent.prototype.addChild !== "function" ||
|
|
51
|
+
typeof UserMessageComponent.prototype.render !== "function"
|
|
52
|
+
) {
|
|
53
|
+
console.warn("[splashscreen] UserMessageComponent shape changed — skipping patch");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
UserMessageComponent[PATCHED] = true;
|
|
58
|
+
|
|
59
|
+
const origAddChild = UserMessageComponent.prototype.addChild;
|
|
60
|
+
UserMessageComponent.prototype.addChild = function (child: any) {
|
|
61
|
+
if (child.paddingX !== undefined && !child._hephaestusPatched) {
|
|
62
|
+
child.paddingX = MSG_PADDING_X;
|
|
63
|
+
child._hephaestusPatched = true;
|
|
64
|
+
}
|
|
65
|
+
if (!instanceIndex.has(this)) {
|
|
66
|
+
instanceIndex.set(this, instanceCount++);
|
|
67
|
+
}
|
|
68
|
+
return origAddChild.call(this, child);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const origRender = UserMessageComponent.prototype.render;
|
|
72
|
+
UserMessageComponent.prototype.render = function (
|
|
73
|
+
width: number,
|
|
74
|
+
): string[] {
|
|
75
|
+
try {
|
|
76
|
+
const currentTheme = getTheme();
|
|
77
|
+
const p = resolvePalette(currentTheme);
|
|
78
|
+
const bg = p.panelBg;
|
|
79
|
+
if (bg !== lastBg) { setThemeBg(currentTheme, "userMessageBg", bg); lastBg = bg; }
|
|
80
|
+
|
|
81
|
+
const idx = instanceIndex.get(this);
|
|
82
|
+
const elapsed = idx !== undefined ? responseTimes[idx] : 0;
|
|
83
|
+
const hasTime = idx !== undefined;
|
|
84
|
+
|
|
85
|
+
const contentWidth = width - TIME_COL;
|
|
86
|
+
const lines: string[] = origRender.call(this, contentWidth);
|
|
87
|
+
if (lines.length < 3) return lines;
|
|
88
|
+
|
|
89
|
+
const timeStr = elapsed > 0 ? formatTime(elapsed) : "";
|
|
90
|
+
const timeRight = 2;
|
|
91
|
+
const timeLabel = timeStr.length > 0 ? p.time(timeStr) : "";
|
|
92
|
+
// Column that shows "42ms" right-aligned with bg — spaces for visual
|
|
93
|
+
// alignment remain, but they're wrapped in panelBg so the copy buffer
|
|
94
|
+
// only contains the label text.
|
|
95
|
+
const timeContent =
|
|
96
|
+
p.panelBg +
|
|
97
|
+
" ".repeat(Math.max(0, TIME_COL - timeStr.length - timeRight)) +
|
|
98
|
+
timeLabel +
|
|
99
|
+
" ".repeat(timeRight);
|
|
100
|
+
// Use \x1b[K (erase-to-end-of-line) instead of literal spaces for the
|
|
101
|
+
// gap between content and time column. Visually fills with panelBg but
|
|
102
|
+
// leaves nothing in the copy buffer.
|
|
103
|
+
const gapFill = p.panelBg + "\x1b[K";
|
|
104
|
+
// Empty column: just bg fill to the end — no spaces in copy buffer
|
|
105
|
+
const emptyTimeCol = p.panelBg + "\x1b[K";
|
|
106
|
+
|
|
107
|
+
const firstContent = 0;
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < lines.length; i++) {
|
|
110
|
+
let line = lines[i]!;
|
|
111
|
+
|
|
112
|
+
// Extract any OSC133 B/C markers (shell zone end/final). v0.67 placed
|
|
113
|
+
// these at the head of the last line; older versions at the tail.
|
|
114
|
+
// Strip them from the content line; re-emit after the time column.
|
|
115
|
+
const oscMatches = line.match(OSC133_RE);
|
|
116
|
+
const oscSuffix = oscMatches ? oscMatches.join("") : "";
|
|
117
|
+
if (oscSuffix) line = line.replace(OSC133_RE, "");
|
|
118
|
+
|
|
119
|
+
// Strip trailing spaces from the content portion (PI pads to contentWidth
|
|
120
|
+
// with plain spaces) and replace with \x1b[K for visual fill without spaces
|
|
121
|
+
// in the copy buffer.
|
|
122
|
+
const strippedLine = line.replace(/((?:\x1b\[[\d;]*m)*)\s+$/, "$1");
|
|
123
|
+
const col = i === firstContent && hasTime ? timeContent : emptyTimeCol;
|
|
124
|
+
lines[i] = strippedLine + gapFill + col + oscSuffix;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return lines;
|
|
128
|
+
} catch {
|
|
129
|
+
// During /resume, getTheme() may throw — fall back to default render
|
|
130
|
+
return origRender.call(this, width);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
/* skip if theme not ready */
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const g: Record<string | symbol, unknown> = globalThis as unknown as typeof global & Record<string | symbol, unknown>;
|
|
2
|
+
|
|
3
|
+
const MODEL_SCOPE_RE = /Model scope:\s*(.+)/;
|
|
4
|
+
export const CAPTURED_MODELS = Symbol.for("splashscreen:capturedModels");
|
|
5
|
+
export const PATCHED_LOG = Symbol.for("splashscreen:logPatched");
|
|
6
|
+
|
|
7
|
+
export function patchConsoleLog(): void {
|
|
8
|
+
if (g[PATCHED_LOG]) return;
|
|
9
|
+
g[PATCHED_LOG] = true;
|
|
10
|
+
const origLog = console.log;
|
|
11
|
+
console.log = (...args: unknown[]) => {
|
|
12
|
+
try {
|
|
13
|
+
if (args.length === 1 && typeof args[0] === "string") {
|
|
14
|
+
const plain = (args[0] as string).replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
|
|
15
|
+
const m = MODEL_SCOPE_RE.exec(plain);
|
|
16
|
+
if (m) {
|
|
17
|
+
const raw = m[1].replace(/\s*\(Ctrl\+\w[\w\s]*\)/gi, "");
|
|
18
|
+
g[CAPTURED_MODELS] = raw
|
|
19
|
+
.split(",")
|
|
20
|
+
.map((s: string) => s.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
/* ignore errors in patching logic */
|
|
27
|
+
}
|
|
28
|
+
origLog.apply(console, args);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { VERSION, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getShinedLogo, TRUECOLOR, LOGO_PAD, LOGO_SETTLE_FRAME } from "./logo.js";
|
|
3
|
+
import { loadCoreConfig } from "../config.js";
|
|
4
|
+
import { detectSection, parseSectionText, parseModelScope, formatColumns, buildItemWrapper, type ParsedSection, SECTION_KEYS } from "./sections.js";
|
|
5
|
+
import { fetchLatestVersion, compareVersions } from "./version.js";
|
|
6
|
+
import { patchConsoleLog } from "./capture.js";
|
|
7
|
+
import { stripAnsi } from "../text.js";
|
|
8
|
+
import { resetInstanceCount } from "../message/index.js";
|
|
9
|
+
import { Text, Spacer, Container, TUI, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
// Symbol keys (survive hot-reload)
|
|
12
|
+
const LISTING_REF = Symbol.for("splashscreen:listingRef");
|
|
13
|
+
const ANIM_INTERVAL = Symbol.for("splashscreen:animInterval");
|
|
14
|
+
const DEBOUNCE_TIMER = Symbol.for("splashscreen:debounceTimer");
|
|
15
|
+
const PATCHED_CLEAR = Symbol.for("splashscreen:clearPatched");
|
|
16
|
+
const PATCHED_LISTING = Symbol.for("splashscreen:listingPatched");
|
|
17
|
+
|
|
18
|
+
// Animation constants
|
|
19
|
+
const MAX_RENDER_WIDTH = 9999;
|
|
20
|
+
const MIN_HEADER_LINES = 11;
|
|
21
|
+
const REVEAL_DEBOUNCE_MS = 150;
|
|
22
|
+
const RAMP_FRAMES = 22;
|
|
23
|
+
const STAGGER_FRAMES = 0;
|
|
24
|
+
const BASE_FADE_DELAY = 3;
|
|
25
|
+
const MAX_STAGGER = BASE_FADE_DELAY + 5 * STAGGER_FRAMES;
|
|
26
|
+
|
|
27
|
+
export interface ListingRef {
|
|
28
|
+
sections: ParsedSection[];
|
|
29
|
+
frame: number;
|
|
30
|
+
revealed: boolean;
|
|
31
|
+
revealedAt: number;
|
|
32
|
+
scaffoldAt: number;
|
|
33
|
+
latestVersion?: string;
|
|
34
|
+
settled: boolean;
|
|
35
|
+
cachedLines?: string[];
|
|
36
|
+
cachedWidth?: number;
|
|
37
|
+
cachedHeight?: number;
|
|
38
|
+
maxHeaderHeight?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── renderHeader() — composes logo + sections + padding ────────────────
|
|
42
|
+
|
|
43
|
+
export function renderHeader(theme: Theme, ref: ListingRef, width: number, height: number): string[] {
|
|
44
|
+
const dim = (t: string) => theme.fg("dim", t);
|
|
45
|
+
const accent = (t: string) => theme.fg("accent", t);
|
|
46
|
+
const logoLines = getShinedLogo(ref.frame, loadCoreConfig().animationStyle);
|
|
47
|
+
|
|
48
|
+
// Use cached text lines if settled (no more animations)
|
|
49
|
+
let listingLines: string[];
|
|
50
|
+
|
|
51
|
+
if (ref.settled && ref.cachedLines && ref.cachedWidth === width && ref.cachedHeight === height) {
|
|
52
|
+
listingLines = ref.cachedLines;
|
|
53
|
+
} else {
|
|
54
|
+
const sectionsToRender: { name: "Version" | ParsedSection["name"]; items: string[] }[] = [];
|
|
55
|
+
|
|
56
|
+
if (!ref.revealed) {
|
|
57
|
+
// Logo only — sections appear together on reveal
|
|
58
|
+
} else {
|
|
59
|
+
const latest = ref.latestVersion ?? VERSION;
|
|
60
|
+
const hasUpdate = compareVersions(latest, VERSION) > 0;
|
|
61
|
+
const latestStr = hasUpdate ? `Latest: ${accent("v" + latest)}` : `Latest: v${latest}`;
|
|
62
|
+
sectionsToRender.push({ name: "Version", items: [`Local: v${VERSION}`, latestStr] });
|
|
63
|
+
|
|
64
|
+
// Display sections in SECTION_KEYS order, skip empty
|
|
65
|
+
const byName = new Map(ref.sections.map(s => [s.name, s]));
|
|
66
|
+
for (const key of SECTION_KEYS) {
|
|
67
|
+
const sec = byName.get(key);
|
|
68
|
+
if (sec && sec.items.length > 0) sectionsToRender.push(sec);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const listingMaxW = Math.floor(width * 0.7);
|
|
73
|
+
listingLines = formatColumns(sectionsToRender, theme, listingMaxW, ref);
|
|
74
|
+
|
|
75
|
+
// Cache once text animations are done
|
|
76
|
+
const textAge = ref.revealed ? ref.frame - ref.revealedAt : 0;
|
|
77
|
+
const textDone = ref.revealed && textAge > RAMP_FRAMES + MAX_STAGGER;
|
|
78
|
+
const logoDone = ref.frame >= LOGO_SETTLE_FRAME;
|
|
79
|
+
|
|
80
|
+
if (textDone && logoDone) {
|
|
81
|
+
ref.settled = true;
|
|
82
|
+
ref.cachedLines = listingLines;
|
|
83
|
+
ref.cachedWidth = width;
|
|
84
|
+
ref.cachedHeight = height;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Build content: logo + separator + listing
|
|
89
|
+
const contentLines: string[] = [];
|
|
90
|
+
|
|
91
|
+
// Logo centered above listing (same center as listing text)
|
|
92
|
+
const listingMaxW = Math.floor(width * 0.7);
|
|
93
|
+
const listingLeftPad = Math.floor((width - listingMaxW) / 2);
|
|
94
|
+
for (const logoRow of logoLines) {
|
|
95
|
+
const pad = Math.floor((listingMaxW - visibleWidth(logoRow)) / 2);
|
|
96
|
+
contentLines.push(" ".repeat(LOGO_PAD) + " ".repeat(listingLeftPad + pad) + logoRow);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Separator
|
|
100
|
+
contentLines.push("");
|
|
101
|
+
|
|
102
|
+
// Listing
|
|
103
|
+
for (const listRow of listingLines) {
|
|
104
|
+
contentLines.push(" ".repeat(LOGO_PAD) + " ".repeat(listingLeftPad) + listRow);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Pad top and bottom to fill height (bias top by 2 for visual centering)
|
|
108
|
+
const contentHeight = contentLines.length;
|
|
109
|
+
const remaining = Math.max(0, height - contentHeight);
|
|
110
|
+
const topPad = Math.floor(remaining / 2) + 5;
|
|
111
|
+
const bottomPad = remaining - topPad;
|
|
112
|
+
|
|
113
|
+
const result: string[] = [];
|
|
114
|
+
for (let i = 0; i < topPad; i++) result.push("");
|
|
115
|
+
for (const line of contentLines) result.push(line);
|
|
116
|
+
for (let i = 0; i < bottomPad; i++) result.push("");
|
|
117
|
+
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Chat container discovery ────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
// Fragile: relies on TUI child ordering (header, chat, footer) which is an
|
|
124
|
+
// internal layout detail of pi's InteractiveMode. If upstream changes the
|
|
125
|
+
// child structure, this will need updating.
|
|
126
|
+
function findChatContainer(tui: TUI): Container | undefined {
|
|
127
|
+
for (const child of tui.children) {
|
|
128
|
+
if (child instanceof Container && child.constructor.name.includes("Scrollable")) {
|
|
129
|
+
return child;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (tui.children.length >= 3) {
|
|
133
|
+
return tui.children[1] as Container;
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function patchStartupListing(
|
|
139
|
+
tui: TUI,
|
|
140
|
+
_theme: Theme,
|
|
141
|
+
ref: ListingRef,
|
|
142
|
+
): void {
|
|
143
|
+
const chat = findChatContainer(tui);
|
|
144
|
+
if (!chat) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const cc = chat as any;
|
|
148
|
+
|
|
149
|
+
// Always update ref + restart animation (critical for /reload)
|
|
150
|
+
cc[LISTING_REF] = ref;
|
|
151
|
+
ref.frame = 0;
|
|
152
|
+
ref.revealed = false;
|
|
153
|
+
ref.revealedAt = 0;
|
|
154
|
+
ref.scaffoldAt = 0;
|
|
155
|
+
ref.settled = false;
|
|
156
|
+
ref.cachedLines = undefined;
|
|
157
|
+
ref.cachedWidth = undefined;
|
|
158
|
+
ref.maxHeaderHeight = undefined;
|
|
159
|
+
|
|
160
|
+
if (cc[ANIM_INTERVAL]) clearInterval(cc[ANIM_INTERVAL]);
|
|
161
|
+
if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
|
|
162
|
+
|
|
163
|
+
const interval = setInterval(() => {
|
|
164
|
+
try {
|
|
165
|
+
const current: ListingRef = cc[LISTING_REF];
|
|
166
|
+
if (!current) {
|
|
167
|
+
clearInterval(interval);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
current.frame++;
|
|
171
|
+
if (current.settled && current.frame >= LOGO_SETTLE_FRAME) {
|
|
172
|
+
clearInterval(interval);
|
|
173
|
+
cc[ANIM_INTERVAL] = null;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
tui.requestRender();
|
|
177
|
+
} catch {
|
|
178
|
+
clearInterval(interval);
|
|
179
|
+
}
|
|
180
|
+
}, 16);
|
|
181
|
+
|
|
182
|
+
cc[ANIM_INTERVAL] = interval;
|
|
183
|
+
|
|
184
|
+
// Fetch latest version from npm
|
|
185
|
+
fetchLatestVersion().then(v => {
|
|
186
|
+
if (v) {
|
|
187
|
+
const current: ListingRef = cc[LISTING_REF];
|
|
188
|
+
current.latestVersion = v;
|
|
189
|
+
// Invalidate cache so version updates on next render
|
|
190
|
+
current.cachedLines = undefined;
|
|
191
|
+
current.settled = false;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Patch clear() to reset message instance tracking on container rebuild
|
|
196
|
+
if (!cc[PATCHED_CLEAR]) {
|
|
197
|
+
cc[PATCHED_CLEAR] = true;
|
|
198
|
+
const origClear = chat.clear.bind(chat);
|
|
199
|
+
chat.clear = () => {
|
|
200
|
+
resetInstanceCount();
|
|
201
|
+
return origClear();
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Only patch addChild once — the closure reads cc[LISTING_REF] dynamically
|
|
206
|
+
if (cc[PATCHED_LISTING]) {
|
|
207
|
+
chat.clear();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
cc[PATCHED_LISTING] = true;
|
|
211
|
+
|
|
212
|
+
const origAddChild = chat.addChild.bind(chat);
|
|
213
|
+
chat.clear();
|
|
214
|
+
|
|
215
|
+
chat.addChild = (component: Component) => {
|
|
216
|
+
try {
|
|
217
|
+
const currentRef: ListingRef = cc[LISTING_REF];
|
|
218
|
+
|
|
219
|
+
if (component instanceof Text) {
|
|
220
|
+
// pi ≥0.67.6 wraps startup sections in ExpandableText; collapsed body
|
|
221
|
+
// is a lossy comma-joined base-name list. Parse the expanded text so
|
|
222
|
+
// Extensions keep real names instead of "index.ts/index.js/index".
|
|
223
|
+
const getExpanded = (component as any).getExpandedText;
|
|
224
|
+
const plain = typeof getExpanded === "function"
|
|
225
|
+
? stripAnsi(getExpanded.call(component))
|
|
226
|
+
: stripAnsi(component.render(MAX_RENDER_WIDTH).join("\n"));
|
|
227
|
+
|
|
228
|
+
const section = parseSectionText(plain) ?? parseModelScope(plain);
|
|
229
|
+
if (section) {
|
|
230
|
+
const existing = currentRef.sections.find(s => s.name === section.name);
|
|
231
|
+
if (existing) {
|
|
232
|
+
existing.items = [...new Set([...existing.items, ...section.items])];
|
|
233
|
+
} else {
|
|
234
|
+
currentRef.sections.push(section);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Invalidate cache so late-arriving sections show up
|
|
238
|
+
currentRef.settled = false;
|
|
239
|
+
currentRef.cachedLines = undefined;
|
|
240
|
+
|
|
241
|
+
if (currentRef.revealed) {
|
|
242
|
+
// Already revealed — show new section immediately
|
|
243
|
+
tui.requestRender();
|
|
244
|
+
} else {
|
|
245
|
+
// Batch initial sections — reset debounce on each arrival
|
|
246
|
+
if (cc[DEBOUNCE_TIMER]) clearTimeout(cc[DEBOUNCE_TIMER]);
|
|
247
|
+
cc[DEBOUNCE_TIMER] = setTimeout(() => {
|
|
248
|
+
const ref: ListingRef = cc[LISTING_REF];
|
|
249
|
+
ref.revealed = true;
|
|
250
|
+
ref.revealedAt = ref.frame;
|
|
251
|
+
ref.scaffoldAt = ref.frame;
|
|
252
|
+
tui.requestRender();
|
|
253
|
+
cc[DEBOUNCE_TIMER] = null;
|
|
254
|
+
}, REVEAL_DEBOUNCE_MS);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (
|
|
261
|
+
plain.includes("Listing all available commands") ||
|
|
262
|
+
plain.includes("(Source: extension)") ||
|
|
263
|
+
plain.trim().startsWith("/skill:")
|
|
264
|
+
) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (component instanceof Spacer && !currentRef.revealed) return;
|
|
270
|
+
try {
|
|
271
|
+
origAddChild(component);
|
|
272
|
+
} catch (e) {
|
|
273
|
+
}
|
|
274
|
+
} catch (e) {
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { gray, rgb, extractRgb, lerp } from "../color.js";
|
|
2
|
+
import type { AnimationStyle } from "../config.js";
|
|
3
|
+
|
|
4
|
+
// ── Truecolor detection ────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export const TRUECOLOR = /truecolor|24bit/i.test(process.env.COLORTERM ?? "")
|
|
7
|
+
|| (process.env.TERM ?? "").includes("256color")
|
|
8
|
+
|| process.env.TERM_PROGRAM === "iTerm.app"
|
|
9
|
+
|| process.env.TERM_PROGRAM === "WezTerm"
|
|
10
|
+
|| process.env.TERM_PROGRAM === "vscode"
|
|
11
|
+
|| process.env.WT_SESSION !== undefined;
|
|
12
|
+
|
|
13
|
+
// ── Logo ───────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export const LOGO = [
|
|
16
|
+
"████████████ ",
|
|
17
|
+
"████████████ ",
|
|
18
|
+
"████ ████ ",
|
|
19
|
+
"████ ████ ",
|
|
20
|
+
"████████ ████",
|
|
21
|
+
"████████ ████",
|
|
22
|
+
"████ ████",
|
|
23
|
+
"████ ████",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export const CHAR_FADE_FRAMES = 22;
|
|
27
|
+
export const LOGO_SETTLE_FRAME = 90;
|
|
28
|
+
export const LOGO_PAD = 0;
|
|
29
|
+
export const LOGO_GAP = 4;
|
|
30
|
+
|
|
31
|
+
const LOGO_COLS = 14;
|
|
32
|
+
const LOGO_ROWS = 8;
|
|
33
|
+
const CENTER_X = (LOGO_COLS - 1) / 2;
|
|
34
|
+
const CENTER_Y = (LOGO_ROWS - 1) / 2;
|
|
35
|
+
|
|
36
|
+
function computeRevealAt(x: number, y: number, style: AnimationStyle): number {
|
|
37
|
+
switch (style) {
|
|
38
|
+
case "diagonal":
|
|
39
|
+
return ((x / 2) * 1.2 + (y / 2) * 3.5) * 1.4;
|
|
40
|
+
case "top-right":
|
|
41
|
+
return (((LOGO_COLS - 1 - x) / 2) * 1.2 + (y / 2) * 3.5) * 1.4;
|
|
42
|
+
case "bottom-left":
|
|
43
|
+
return ((x / 2) * 1.2 + ((LOGO_ROWS - 1 - y) / 2) * 3.5) * 1.4;
|
|
44
|
+
case "bottom-right":
|
|
45
|
+
return (((LOGO_COLS - 1 - x) / 2) * 1.2 + ((LOGO_ROWS - 1 - y) / 2) * 3.5) * 1.4;
|
|
46
|
+
case "center-out": {
|
|
47
|
+
const dist = Math.sqrt((x - CENTER_X) ** 2 + (y - CENTER_Y) ** 2);
|
|
48
|
+
return dist * 4.5;
|
|
49
|
+
}
|
|
50
|
+
case "wave": {
|
|
51
|
+
const base = ((x / 2) * 1.2 + (y / 2) * 3.5) * 1.4;
|
|
52
|
+
const wave = Math.sin((x * 0.8 + y * 0.5) * 1.2) * 8;
|
|
53
|
+
return base + wave;
|
|
54
|
+
}
|
|
55
|
+
case "horizontal":
|
|
56
|
+
return x * 3.5;
|
|
57
|
+
case "vertical":
|
|
58
|
+
return y * 5.5;
|
|
59
|
+
case "vertical-up":
|
|
60
|
+
return (LOGO_ROWS - 1 - y) * 5.5;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getShinedLogo(frame: number, style: AnimationStyle = "wave"): string[] {
|
|
65
|
+
if (!TRUECOLOR) return LOGO;
|
|
66
|
+
|
|
67
|
+
return LOGO.map((line, y) => {
|
|
68
|
+
let result = "";
|
|
69
|
+
for (let x = 0; x < line.length; x++) {
|
|
70
|
+
const char = line[x];
|
|
71
|
+
if (char === " ") { result += " "; continue; }
|
|
72
|
+
|
|
73
|
+
const revealAt = computeRevealAt(x, y, style);
|
|
74
|
+
const age = frame - revealAt;
|
|
75
|
+
|
|
76
|
+
if (age <= 0) { result += " "; continue; }
|
|
77
|
+
|
|
78
|
+
const t = Math.min(1, age / CHAR_FADE_FRAMES);
|
|
79
|
+
const eased = 1 - (1 - t) * (1 - t);
|
|
80
|
+
const brightness = Math.floor(lerp(50, 255, eased));
|
|
81
|
+
result += gray(brightness, char);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
});
|
|
85
|
+
}
|