pi-incarnate-ui 0.1.0 → 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/CHANGELOG.md +10 -1
- package/README.md +6 -5
- package/SECURITY.md +5 -3
- package/extensions/index.ts +21 -7
- package/package.json +1 -1
- package/src/avatar-viewer.ts +80 -11
- package/src/footer-component.ts +81 -0
- package/src/inline-image.ts +22 -0
- package/src/layout.ts +38 -13
- package/src/protocol.ts +60 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0 - 2026-09-11
|
|
6
|
+
|
|
7
|
+
- Render validated PNG avatars through Pi's inline-image component with aspect-ratio-preserving Footer and overlay bounds.
|
|
8
|
+
- Fall back automatically to ANSI/TXT or compact character status when image protocols are unavailable, disabled, or fail to render.
|
|
9
|
+
- Reuse image IDs across redraws and cover Kitty, iTerm2, resize, overlay, and no-image capability branches.
|
|
10
|
+
- Keep primary character values readable when Pi's configured theme does not match the terminal background by using the terminal foreground with bold emphasis.
|
|
11
|
+
|
|
12
|
+
## 0.1.0 - 2026-09-09
|
|
4
13
|
|
|
5
14
|
- Preserve the public portion of Pi's default Footer semantics: assistant/tool/compaction/branch-summary usage, cache reads and writes, latest cache hit rate, session name, multi-provider labels, known Kimi subscription status, and context-pressure colors.
|
|
6
15
|
- Add a real-entry integration gate against the sibling `pi-incarnate` workspace and run it in CI for both supported Node versions.
|
package/README.md
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
它使用 Pi 的公开 `setFooter()` API,把角色呈现和必要的 Pi 运行信息组合成固定底部区域:
|
|
6
6
|
|
|
7
7
|
- 固定区域严格不超过 6 行,避免压缩对话和输入框。
|
|
8
|
-
-
|
|
8
|
+
- 宽终端优先在 4 行预算内等比显示 `avatar.png`,旁边显示角色、mood、来源和表单状态;没有 PNG 或图片协议不可用时使用 ANSI/TXT 四行采样回退。
|
|
9
9
|
- 终端窄于 72 列或低于 20 行时,自动降级为 1 行角色状态加 2 行运行信息。
|
|
10
10
|
- `off`、未启用角色或核心扩展没有响应时完全归还 Pi 默认 Footer,不占用空闲布局。
|
|
11
11
|
- 保留工作目录、Git 分支、session 名称、输入/输出与缓存 token、缓存命中率、cost、上下文占用、模型、provider、thinking 和其他扩展状态。
|
|
12
12
|
- Pi 默认启动 Header、对话、工具输出和输入框保持原样。
|
|
13
13
|
- `/incarnate-ui` 可按需打开完整头像浮层,Enter、Esc 或 `q` 关闭;浮层关闭后返回原输入界面。
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
当前版本为 `0.2.0`,新增 PNG 等比头像、兼容回退和主题错配时的主信息对比度保护。可使用 `pi install npm:pi-incarnate-ui` 安装;当前仍不处理与其他自定义 Footer 扩展的共存。
|
|
16
16
|
|
|
17
17
|
## Development
|
|
18
18
|
|
|
@@ -27,7 +27,7 @@ pi --no-extensions \
|
|
|
27
27
|
|
|
28
28
|
`test:companion` 要求核心仓库位于同级目录 `/path/to/pi-incarnate`。它加载两个真实扩展入口,验证两种加载顺序、状态生命周期、未知协议版本和缺失另一端的降级。
|
|
29
29
|
|
|
30
|
-
启动后通过 `/incarnate` 选择角色。核心扩展只管理人格、角色资源和安全清理;所有终端绘制均由本 UI 扩展负责。未加载 UI 时人格仍然生效,但不显示角色图。
|
|
30
|
+
启动后通过 `/incarnate` 选择角色。核心扩展只管理人格、角色资源和安全清理;所有终端绘制均由本 UI 扩展负责。未加载 UI 时人格仍然生效,但不显示角色图。PNG 通过 Pi 的 `Image` 组件使用 Kitty 或 iTerm2 协议渲染,按原始像素尺寸与终端 cell 尺寸等比缩放;Pi 禁用图片或终端不支持时不会输出占位图。
|
|
31
31
|
|
|
32
32
|
需要查看未经四行采样的完整头像时运行:
|
|
33
33
|
|
|
@@ -35,14 +35,15 @@ pi --no-extensions \
|
|
|
35
35
|
/incarnate-ui
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
查看器只在 TUI
|
|
38
|
+
查看器只在 TUI 中打开临时居中浮层,使用核心已经验证的 PNG 或清理并限制为最多 48×16 的文本头像数据,不读取角色目录,也不改变 Footer 模式。PNG 在浮层可用空间内等比缩放;文本头像在终端高度不足时可用 `↑` / `↓` 滚动查看全部行。
|
|
39
39
|
|
|
40
40
|
## 当前边界
|
|
41
41
|
|
|
42
42
|
- 只在 TUI 模式设置 Footer;RPC、JSON 和 print 模式不接管表现层。
|
|
43
43
|
- 角色状态由版本化的 `pi.events` 协议传递,UI 不直接读取角色目录。
|
|
44
44
|
- `compact` 使用 3 行布局;`auto` 和 `full` 在宽度足够时使用最多 6 行布局;`off` 或未激活状态不接管 Footer。
|
|
45
|
-
-
|
|
45
|
+
- PNG 常驻预览保持宽高比并限制为四行;ANSI/TXT 回退仍从完整文本资源抽样,因此不等同于完整原图。完整内容由按需查看器提供。
|
|
46
|
+
- PNG-only 角色在图片协议不可用时降级为紧凑角色状态;建议同时提供 `avatar.ansi` 或 `avatar.txt`。
|
|
46
47
|
- 完整头像查看器是临时 overlay,不占用常驻布局;无活动头像或非 TUI 模式时只给出提示。
|
|
47
48
|
- 当前角色状态仍由 pi-incarnate 按 session 管理;新会话默认关闭,需要重新选择角色,此时显示 Pi 自身的默认 Footer。
|
|
48
49
|
- Pi 0.85.0 的公开扩展上下文不暴露通用订阅检测和 auto-compaction 开关;UI 只保留默认 Footer 可公开重建的信息,并沿用 Pi 对 `kimi-coding` 的已知订阅标记。其他 provider 的内部订阅状态与 auto-compaction 标记不显示。
|
package/SECURITY.md
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Pi extensions run with the same permissions as Pi. Review extension source before loading it.
|
|
4
4
|
|
|
5
|
-
`pi-incarnate-ui` does not read character cards or avatar files. It receives a bounded UI snapshot over Pi's in-process event bus. The companion `pi-incarnate` extension remains responsible for validating character paths, decoding UTF-8, filtering ANSI sequences, limiting avatar dimensions, and forcing terminal color resets.
|
|
5
|
+
`pi-incarnate-ui` does not read character cards or avatar files. It receives a bounded UI snapshot over Pi's in-process event bus. The companion `pi-incarnate` extension remains responsible for validating character paths and PNG structure, decoding UTF-8, filtering ANSI sequences, limiting avatar dimensions, and forcing terminal color resets.
|
|
6
6
|
|
|
7
|
-
The Footer renderer bounds every output line to the available terminal width
|
|
7
|
+
The Footer renderer bounds every output line to the available terminal width and emits at most six rows. Protocol validation accepts at most 16 text-avatar lines and 48 visible columns, or a PNG up to 512 KiB, 2048 px per dimension, and 4,194,304 pixels. It verifies canonical base64, the PNG signature, IHDR dimensions, and metadata consistency before passing image data to Pi's `Image` component. Character and runtime metadata is stripped of terminal control sequences before display. Unknown protocol versions and structurally invalid payloads are ignored.
|
|
8
8
|
|
|
9
|
-
The optional full-avatar overlay renders only the same bounded in-memory snapshot; it does not read files.
|
|
9
|
+
The optional full-avatar overlay renders only the same bounded in-memory snapshot; it does not read files. PNG rendering preserves aspect ratio within the assigned overlay bounds; text rows are clipped to the assigned width. Its title metadata is sanitized, and it closes without changing character state.
|
|
10
|
+
|
|
11
|
+
Inline PNG output is emitted only when Pi reports Kitty or iTerm2 image support. Unsupported, disabled, or failed image rendering falls back to the supplied ANSI/TXT avatar; PNG-only characters fall back to the compact status row. Image IDs are reused across redraws and size changes so Pi can replace placements without accumulating stale image data.
|
|
10
12
|
|
|
11
13
|
This extension replaces Pi's Footer only while a compatible active character requests visible presentation in TUI mode. It restores Pi's default Footer when the character is inactive or presentation is off, and never claims the Footer if no compatible core state arrives. It does not replace the startup Header, editor, transcript renderer, or tool UI. Other custom Footer extensions may replace it according to extension load order.
|
package/extensions/index.ts
CHANGED
|
@@ -4,10 +4,11 @@ import type {
|
|
|
4
4
|
ReadonlyFooterDataProvider,
|
|
5
5
|
Theme,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import type
|
|
7
|
+
import { getCapabilities, type TUI } from "@earendil-works/pi-tui";
|
|
8
8
|
|
|
9
9
|
import { AvatarViewerComponent } from "../src/avatar-viewer.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { IncarnateFooterComponent } from "../src/footer-component.ts";
|
|
11
|
+
import { type FooterSessionInfo, type FooterStyles } from "../src/layout.ts";
|
|
11
12
|
import {
|
|
12
13
|
isIncarnateUiStateV1,
|
|
13
14
|
UI_REQUEST_STATE_EVENT,
|
|
@@ -22,7 +23,9 @@ function themeStyles(theme: Theme): FooterStyles {
|
|
|
22
23
|
accent: (text) => theme.fg("accent", text),
|
|
23
24
|
muted: (text) => theme.fg("muted", text),
|
|
24
25
|
dim: (text) => theme.fg("dim", text),
|
|
25
|
-
|
|
26
|
+
// Use the terminal foreground for primary values. This stays readable when
|
|
27
|
+
// Pi's configured light/dark theme does not match the terminal background.
|
|
28
|
+
text: (text) => theme.bold(text),
|
|
26
29
|
success: (text) => theme.fg("success", text),
|
|
27
30
|
warning: (text) => theme.fg("warning", text),
|
|
28
31
|
error: (text) => theme.fg("error", text),
|
|
@@ -120,11 +123,18 @@ export default function incarnateUiExtension(pi: ExtensionAPI): void {
|
|
|
120
123
|
context.ui.setFooter((footerTui, theme, footerData) => {
|
|
121
124
|
tui = footerTui;
|
|
122
125
|
const unsubscribe = footerData.onBranchChange(requestRender);
|
|
126
|
+
const footer = new IncarnateFooterComponent(
|
|
127
|
+
() => state,
|
|
128
|
+
() => sessionInfo(context, footerData, footerTui),
|
|
129
|
+
themeStyles(theme),
|
|
130
|
+
);
|
|
123
131
|
return {
|
|
124
132
|
render(width: number): string[] {
|
|
125
|
-
return
|
|
133
|
+
return footer.render(width);
|
|
134
|
+
},
|
|
135
|
+
invalidate() {
|
|
136
|
+
footer.invalidate();
|
|
126
137
|
},
|
|
127
|
-
invalidate() {},
|
|
128
138
|
dispose() {
|
|
129
139
|
unsubscribe();
|
|
130
140
|
if (tui === footerTui) tui = undefined;
|
|
@@ -165,17 +175,21 @@ export default function incarnateUiExtension(pi: ExtensionAPI): void {
|
|
|
165
175
|
ctx.ui.notify("The full avatar viewer is available only in TUI mode", "warning");
|
|
166
176
|
return;
|
|
167
177
|
}
|
|
168
|
-
if (!state.active || !state.avatar
|
|
178
|
+
if (!state.active || !state.avatar || (!state.avatar.lines.length && !state.avatar.image)) {
|
|
169
179
|
ctx.ui.notify("No active avatar. Enable a character and its avatar first.", "warning");
|
|
170
180
|
return;
|
|
171
181
|
}
|
|
182
|
+
if (!state.avatar.lines.length && state.avatar.image && !getCapabilities().images) {
|
|
183
|
+
ctx.ui.notify("This terminal cannot render PNG avatars. Add avatar.ansi or avatar.txt as a fallback.", "warning");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
172
186
|
await ctx.ui.custom<void>(
|
|
173
187
|
(overlayTui, theme, _keybindings, done) => new AvatarViewerComponent(state, theme, done, overlayTui),
|
|
174
188
|
{
|
|
175
189
|
overlay: true,
|
|
176
190
|
overlayOptions: {
|
|
177
191
|
anchor: "center",
|
|
178
|
-
width: Math.max(24, Math.min(54, state.avatar.width + 4)),
|
|
192
|
+
width: state.avatar.image ? 54 : Math.max(24, Math.min(54, state.avatar.width + 4)),
|
|
179
193
|
maxHeight: 20,
|
|
180
194
|
margin: 1,
|
|
181
195
|
},
|
package/package.json
CHANGED
package/src/avatar-viewer.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Image,
|
|
4
|
+
compositeTuiLine,
|
|
5
|
+
getCapabilities,
|
|
6
|
+
matchesKey,
|
|
7
|
+
stripTerminalSequences,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
type Focusable,
|
|
11
|
+
type TUI,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
3
13
|
|
|
4
14
|
import type { IncarnateUiStateV1 } from "./protocol.ts";
|
|
15
|
+
import { calculateBoundedImageSize, containsInlineImage } from "./inline-image.ts";
|
|
5
16
|
|
|
6
17
|
export interface AvatarViewerStyles {
|
|
7
18
|
border(text: string): string;
|
|
@@ -15,6 +26,11 @@ export const plainAvatarViewerStyles: AvatarViewerStyles = {
|
|
|
15
26
|
dim: (text) => text,
|
|
16
27
|
};
|
|
17
28
|
|
|
29
|
+
export interface RenderedViewerImage {
|
|
30
|
+
lines: string[];
|
|
31
|
+
width: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
function sanitizeInline(value: string): string {
|
|
19
35
|
return stripTerminalSequences(value)
|
|
20
36
|
.replace(/[\r\n\t\u0000-\u001f\u007f-\u009f]+/g, " ")
|
|
@@ -40,11 +56,12 @@ export function renderAvatarViewer(
|
|
|
40
56
|
styles: AvatarViewerStyles = plainAvatarViewerStyles,
|
|
41
57
|
offset = 0,
|
|
42
58
|
maxAvatarRows = Number.POSITIVE_INFINITY,
|
|
59
|
+
renderedImage?: RenderedViewerImage,
|
|
43
60
|
): string[] {
|
|
44
61
|
const safeWidth = Math.max(1, width);
|
|
45
62
|
if (safeWidth < 4) return [truncateToWidth("avatar", safeWidth, "")];
|
|
46
63
|
const innerWidth = safeWidth - 2;
|
|
47
|
-
if (!state.active || !state.character || !state.avatar?.lines.length) {
|
|
64
|
+
if (!state.active || !state.character || (!state.avatar?.lines.length && !renderedImage)) {
|
|
48
65
|
return [
|
|
49
66
|
styles.border(`╭${"─".repeat(innerWidth)}╮`),
|
|
50
67
|
`${styles.border("│")}${pad(styles.dim(" No active avatar"), innerWidth)}${styles.border("│")}`,
|
|
@@ -56,13 +73,18 @@ export function renderAvatarViewer(
|
|
|
56
73
|
const id = sanitizeInline(state.character.id) || "unknown";
|
|
57
74
|
const title = truncateToWidth(` ${name} / ${id} · full avatar `, innerWidth, "");
|
|
58
75
|
const top = `${styles.border("╭")}${styles.title(title)}${styles.border("─".repeat(Math.max(0, innerWidth - visibleWidth(title))))}${styles.border("╮")}`;
|
|
59
|
-
const start = Math.max(0, Math.min(Math.floor(offset), Math.max(0, state.avatar
|
|
60
|
-
const visibleLines =
|
|
61
|
-
|
|
62
|
-
(
|
|
63
|
-
)
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
const start = Math.max(0, Math.min(Math.floor(offset), Math.max(0, (state.avatar?.lines.length ?? 0) - 1)));
|
|
77
|
+
const visibleLines = renderedImage
|
|
78
|
+
? renderedImage.lines
|
|
79
|
+
: state.avatar!.lines.slice(start, start + Math.max(1, Math.floor(maxAvatarRows)));
|
|
80
|
+
const avatarRows = visibleLines.map((line) => {
|
|
81
|
+
if (!renderedImage) return `${styles.border("│")}${center(line, innerWidth)}${styles.border("│")}`;
|
|
82
|
+
const left = 1 + Math.floor(Math.max(0, innerWidth - renderedImage.width) / 2);
|
|
83
|
+
const base = `${styles.border("│")}${" ".repeat(innerWidth)}${styles.border("│")}`;
|
|
84
|
+
return compositeTuiLine(base, line, left, renderedImage.width, safeWidth);
|
|
85
|
+
});
|
|
86
|
+
const range = !renderedImage && visibleLines.length < state.avatar!.lines.length
|
|
87
|
+
? ` ${start + 1}-${start + visibleLines.length}/${state.avatar!.lines.length} · ↑/↓ ·`
|
|
66
88
|
: "";
|
|
67
89
|
const help = `${styles.border("│")}${pad(styles.dim(`${range} Enter / Esc / q · close`), innerWidth)}${styles.border("│")}`;
|
|
68
90
|
const bottom = styles.border(`╰${"─".repeat(innerWidth)}╯`);
|
|
@@ -84,6 +106,11 @@ export class AvatarViewerComponent implements Focusable {
|
|
|
84
106
|
private readonly done: () => void;
|
|
85
107
|
private readonly tui: TUI | undefined;
|
|
86
108
|
private offset = 0;
|
|
109
|
+
private image?: Image;
|
|
110
|
+
private imageData?: string;
|
|
111
|
+
private imageProtocol?: string;
|
|
112
|
+
private imageColumns?: number;
|
|
113
|
+
private imageRows?: number;
|
|
87
114
|
|
|
88
115
|
constructor(state: IncarnateUiStateV1, theme: Theme, done: () => void, tui?: TUI) {
|
|
89
116
|
this.state = state;
|
|
@@ -119,9 +146,51 @@ export class AvatarViewerComponent implements Focusable {
|
|
|
119
146
|
return Math.max(1, (this.tui?.terminal.rows ?? 24) - 5);
|
|
120
147
|
}
|
|
121
148
|
|
|
149
|
+
private renderImage(width: number): RenderedViewerImage | undefined {
|
|
150
|
+
const source = this.state.avatar?.image;
|
|
151
|
+
const protocol = getCapabilities().images;
|
|
152
|
+
if (!source || !protocol || width < 4) return undefined;
|
|
153
|
+
const size = calculateBoundedImageSize(
|
|
154
|
+
{ widthPx: source.widthPx, heightPx: source.heightPx },
|
|
155
|
+
Math.max(1, width - 2),
|
|
156
|
+
this.viewportRows(),
|
|
157
|
+
);
|
|
158
|
+
if (
|
|
159
|
+
!this.image ||
|
|
160
|
+
this.imageData !== source.data ||
|
|
161
|
+
this.imageProtocol !== protocol ||
|
|
162
|
+
this.imageColumns !== size.columns ||
|
|
163
|
+
this.imageRows !== size.rows
|
|
164
|
+
) {
|
|
165
|
+
const imageId = this.image?.getImageId();
|
|
166
|
+
this.image = new Image(
|
|
167
|
+
source.data,
|
|
168
|
+
source.mimeType,
|
|
169
|
+
{ fallbackColor: (text) => this.theme.fg("dim", text) },
|
|
170
|
+
{ maxWidthCells: size.columns, maxHeightCells: size.rows, filename: "avatar.png", imageId },
|
|
171
|
+
{ widthPx: source.widthPx, heightPx: source.heightPx },
|
|
172
|
+
);
|
|
173
|
+
this.imageData = source.data;
|
|
174
|
+
this.imageProtocol = protocol;
|
|
175
|
+
this.imageColumns = size.columns;
|
|
176
|
+
this.imageRows = size.rows;
|
|
177
|
+
}
|
|
178
|
+
const lines = this.image.render(size.columns + 2);
|
|
179
|
+
return containsInlineImage(lines) ? { lines, width: size.columns } : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
122
182
|
render(width: number): string[] {
|
|
123
|
-
return renderAvatarViewer(
|
|
183
|
+
return renderAvatarViewer(
|
|
184
|
+
width,
|
|
185
|
+
this.state,
|
|
186
|
+
viewerStyles(this.theme),
|
|
187
|
+
this.offset,
|
|
188
|
+
this.viewportRows(),
|
|
189
|
+
this.renderImage(width),
|
|
190
|
+
);
|
|
124
191
|
}
|
|
125
192
|
|
|
126
|
-
invalidate(): void {
|
|
193
|
+
invalidate(): void {
|
|
194
|
+
this.image?.invalidate();
|
|
195
|
+
}
|
|
127
196
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Image,
|
|
3
|
+
getCapabilities,
|
|
4
|
+
type Component,
|
|
5
|
+
} from "@earendil-works/pi-tui";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
footerImageBounds,
|
|
9
|
+
renderFooter,
|
|
10
|
+
type FooterSessionInfo,
|
|
11
|
+
type FooterStyles,
|
|
12
|
+
type RenderedAvatarImage,
|
|
13
|
+
} from "./layout.ts";
|
|
14
|
+
import type { IncarnateUiStateV1 } from "./protocol.ts";
|
|
15
|
+
import { calculateBoundedImageSize, containsInlineImage } from "./inline-image.ts";
|
|
16
|
+
|
|
17
|
+
export class IncarnateFooterComponent implements Component {
|
|
18
|
+
private readonly getState: () => IncarnateUiStateV1;
|
|
19
|
+
private readonly getSession: () => FooterSessionInfo;
|
|
20
|
+
private readonly styles: FooterStyles;
|
|
21
|
+
private image?: Image;
|
|
22
|
+
private imageData?: string;
|
|
23
|
+
private imageProtocol?: string;
|
|
24
|
+
private imageColumns?: number;
|
|
25
|
+
private imageRows?: number;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
getState: () => IncarnateUiStateV1,
|
|
29
|
+
getSession: () => FooterSessionInfo,
|
|
30
|
+
styles: FooterStyles,
|
|
31
|
+
) {
|
|
32
|
+
this.getState = getState;
|
|
33
|
+
this.getSession = getSession;
|
|
34
|
+
this.styles = styles;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private renderImage(width: number, state: IncarnateUiStateV1, session: FooterSessionInfo): RenderedAvatarImage | undefined {
|
|
38
|
+
const source = state.avatar?.image;
|
|
39
|
+
const bounds = footerImageBounds(width, state, session);
|
|
40
|
+
const protocol = getCapabilities().images;
|
|
41
|
+
if (!source || !bounds || !protocol) return undefined;
|
|
42
|
+
|
|
43
|
+
const size = calculateBoundedImageSize(
|
|
44
|
+
{ widthPx: source.widthPx, heightPx: source.heightPx },
|
|
45
|
+
bounds.maxWidth,
|
|
46
|
+
bounds.maxHeight,
|
|
47
|
+
);
|
|
48
|
+
if (
|
|
49
|
+
!this.image ||
|
|
50
|
+
this.imageData !== source.data ||
|
|
51
|
+
this.imageProtocol !== protocol ||
|
|
52
|
+
this.imageColumns !== size.columns ||
|
|
53
|
+
this.imageRows !== size.rows
|
|
54
|
+
) {
|
|
55
|
+
const imageId = this.image?.getImageId();
|
|
56
|
+
this.image = new Image(
|
|
57
|
+
source.data,
|
|
58
|
+
source.mimeType,
|
|
59
|
+
{ fallbackColor: this.styles.dim },
|
|
60
|
+
{ maxWidthCells: size.columns, maxHeightCells: size.rows, filename: "avatar.png", imageId },
|
|
61
|
+
{ widthPx: source.widthPx, heightPx: source.heightPx },
|
|
62
|
+
);
|
|
63
|
+
this.imageData = source.data;
|
|
64
|
+
this.imageProtocol = protocol;
|
|
65
|
+
this.imageColumns = size.columns;
|
|
66
|
+
this.imageRows = size.rows;
|
|
67
|
+
}
|
|
68
|
+
const lines = this.image.render(size.columns + 2);
|
|
69
|
+
return containsInlineImage(lines) ? { lines, width: size.columns } : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
render(width: number): string[] {
|
|
73
|
+
const state = this.getState();
|
|
74
|
+
const session = this.getSession();
|
|
75
|
+
return renderFooter(width, state, session, this.styles, this.renderImage(width, state, session));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
invalidate(): void {
|
|
79
|
+
this.image?.invalidate();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getCellDimensions } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export function calculateBoundedImageSize(
|
|
4
|
+
dimensions: { widthPx: number; heightPx: number },
|
|
5
|
+
maxWidthCells: number,
|
|
6
|
+
maxHeightCells: number,
|
|
7
|
+
): { columns: number; rows: number } {
|
|
8
|
+
const maximumWidth = Math.max(1, Math.floor(maxWidthCells));
|
|
9
|
+
const maximumHeight = Math.max(1, Math.floor(maxHeightCells));
|
|
10
|
+
const cells = getCellDimensions();
|
|
11
|
+
const widthScale = (maximumWidth * cells.widthPx) / Math.max(1, dimensions.widthPx);
|
|
12
|
+
const heightScale = (maximumHeight * cells.heightPx) / Math.max(1, dimensions.heightPx);
|
|
13
|
+
const scale = Math.min(widthScale, heightScale);
|
|
14
|
+
return {
|
|
15
|
+
columns: Math.max(1, Math.min(maximumWidth, Math.ceil((dimensions.widthPx * scale) / cells.widthPx))),
|
|
16
|
+
rows: Math.max(1, Math.min(maximumHeight, Math.ceil((dimensions.heightPx * scale) / cells.heightPx))),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function containsInlineImage(lines: string[]): boolean {
|
|
21
|
+
return lines.some((line) => line.includes("\u001b_G") || line.includes("\u001b]1337;File="));
|
|
22
|
+
}
|
package/src/layout.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { compositeTuiLine, stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
import type { IncarnateUiStateV1 } from "./protocol.ts";
|
|
5
5
|
|
|
@@ -9,6 +9,11 @@ const MIN_PORTRAIT_INFO_COLUMNS = 26;
|
|
|
9
9
|
const AUTO_PORTRAIT_MIN_COLUMNS = 72;
|
|
10
10
|
const AUTO_PORTRAIT_MIN_TERMINAL_ROWS = 20;
|
|
11
11
|
|
|
12
|
+
export interface RenderedAvatarImage {
|
|
13
|
+
lines: string[];
|
|
14
|
+
width: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
export interface FooterSessionInfo {
|
|
13
18
|
model?: string;
|
|
14
19
|
provider?: string;
|
|
@@ -130,22 +135,36 @@ export function sampleAvatarLines(lines: string[], rows = PORTRAIT_ROWS): string
|
|
|
130
135
|
});
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
function portraitModeEnabled(width: number, state: IncarnateUiStateV1, session: FooterSessionInfo): boolean {
|
|
139
|
+
if (!state.active || !state.character || state.avatarMode === "off" || state.avatarMode === "compact") return false;
|
|
140
|
+
return !(
|
|
141
|
+
state.avatarMode === "auto" &&
|
|
142
|
+
(width < AUTO_PORTRAIT_MIN_COLUMNS || (session.terminalRows ?? 24) < AUTO_PORTRAIT_MIN_TERMINAL_ROWS)
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function footerImageBounds(
|
|
147
|
+
width: number,
|
|
148
|
+
state: IncarnateUiStateV1,
|
|
149
|
+
session: FooterSessionInfo,
|
|
150
|
+
): { maxWidth: number; maxHeight: number } | undefined {
|
|
151
|
+
if (!state.avatar?.image || !portraitModeEnabled(width, state, session)) return undefined;
|
|
152
|
+
const maxWidth = Math.min(48, width - MIN_PORTRAIT_INFO_COLUMNS - 3);
|
|
153
|
+
return maxWidth >= 4 ? { maxWidth, maxHeight: PORTRAIT_ROWS } : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
133
156
|
function portraitRows(
|
|
134
157
|
width: number,
|
|
135
158
|
state: IncarnateUiStateV1,
|
|
136
159
|
session: FooterSessionInfo,
|
|
137
160
|
styles: FooterStyles,
|
|
161
|
+
renderedImage?: RenderedAvatarImage,
|
|
138
162
|
): string[] | undefined {
|
|
139
|
-
if (!state.active || !state.character || !state.avatar
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
state.avatarMode === "auto" &&
|
|
143
|
-
(width < AUTO_PORTRAIT_MIN_COLUMNS || (session.terminalRows ?? 24) < AUTO_PORTRAIT_MIN_TERMINAL_ROWS)
|
|
144
|
-
) {
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
163
|
+
if (!state.active || !state.character || !state.avatar || !portraitModeEnabled(width, state, session)) return undefined;
|
|
164
|
+
const useImage = Boolean(renderedImage?.lines.length && state.avatar.image);
|
|
165
|
+
if (!useImage && state.avatar.lines.length === 0) return undefined;
|
|
147
166
|
|
|
148
|
-
const avatarWidth = Math.min(state.avatar.width, width);
|
|
167
|
+
const avatarWidth = Math.min(useImage ? renderedImage!.width : state.avatar.width, width);
|
|
149
168
|
const infoWidth = width - avatarWidth - 3;
|
|
150
169
|
if (avatarWidth <= 0 || infoWidth < MIN_PORTRAIT_INFO_COLUMNS) return undefined;
|
|
151
170
|
|
|
@@ -159,10 +178,15 @@ function portraitRows(
|
|
|
159
178
|
`${styles.muted("forms")} ${styles.text(`${character.forms.available}/${character.forms.total}`)} ${styles.dim(`· ${character.source}`)}`,
|
|
160
179
|
styles.dim("/incarnate · configure"),
|
|
161
180
|
];
|
|
162
|
-
|
|
181
|
+
const sourceLines = useImage
|
|
182
|
+
? [...renderedImage!.lines, ...Array.from({ length: Math.max(0, PORTRAIT_ROWS - renderedImage!.lines.length) }, () => "")]
|
|
183
|
+
.slice(0, PORTRAIT_ROWS)
|
|
184
|
+
: sampleAvatarLines(state.avatar.lines);
|
|
185
|
+
return sourceLines.map((line, index) => {
|
|
163
186
|
const avatar = padToWidth(line, avatarWidth);
|
|
164
187
|
const details = padToWidth(info[index] ?? "", infoWidth);
|
|
165
|
-
|
|
188
|
+
const base = `${" ".repeat(avatarWidth)} ${styles.accent("│")} ${details}`;
|
|
189
|
+
return useImage ? compositeTuiLine(base, line, 0, avatarWidth, width) : `${avatar} ${styles.accent("│")} ${details}`;
|
|
166
190
|
});
|
|
167
191
|
}
|
|
168
192
|
|
|
@@ -185,8 +209,9 @@ export function renderFooter(
|
|
|
185
209
|
state: IncarnateUiStateV1,
|
|
186
210
|
session: FooterSessionInfo = {},
|
|
187
211
|
styles: FooterStyles = plainFooterStyles,
|
|
212
|
+
renderedImage?: RenderedAvatarImage,
|
|
188
213
|
): string[] {
|
|
189
214
|
const safeWidth = Math.max(1, width);
|
|
190
|
-
const character = portraitRows(safeWidth, state, session, styles) ?? compactCharacterRow(safeWidth, state, styles);
|
|
215
|
+
const character = portraitRows(safeWidth, state, session, styles, renderedImage) ?? compactCharacterRow(safeWidth, state, styles);
|
|
191
216
|
return [...character, ...systemRows(safeWidth, session, styles)].slice(0, MAX_FOOTER_ROWS);
|
|
192
217
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -3,6 +3,17 @@ export const UI_STATE_EVENT = "pi-incarnate:ui:state-v1";
|
|
|
3
3
|
|
|
4
4
|
export type CharacterSource = "built-in" | "personal";
|
|
5
5
|
export type AvatarMode = "auto" | "full" | "compact" | "off";
|
|
6
|
+
export const AVATAR_PNG_MAX_BYTES = 512 * 1024;
|
|
7
|
+
export const AVATAR_PNG_MAX_DIMENSION = 2048;
|
|
8
|
+
export const AVATAR_PNG_MAX_PIXELS = 2048 * 2048;
|
|
9
|
+
|
|
10
|
+
export interface IncarnateAvatarImageV1 {
|
|
11
|
+
mimeType: "image/png";
|
|
12
|
+
data: string;
|
|
13
|
+
widthPx: number;
|
|
14
|
+
heightPx: number;
|
|
15
|
+
bytes: number;
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
export interface IncarnateUiStateV1 {
|
|
8
19
|
version: 1;
|
|
@@ -23,9 +34,56 @@ export interface IncarnateUiStateV1 {
|
|
|
23
34
|
width: number;
|
|
24
35
|
height: number;
|
|
25
36
|
truncated: boolean;
|
|
37
|
+
image?: IncarnateAvatarImageV1;
|
|
26
38
|
};
|
|
27
39
|
}
|
|
28
40
|
|
|
41
|
+
function decodeCanonicalPng(value: string, expectedBytes: number): Buffer | undefined {
|
|
42
|
+
if (
|
|
43
|
+
value.length === 0 ||
|
|
44
|
+
value.length > Math.ceil(AVATAR_PNG_MAX_BYTES / 3) * 4 ||
|
|
45
|
+
value.length % 4 !== 0 ||
|
|
46
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
|
|
47
|
+
) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
|
51
|
+
if ((value.length / 4) * 3 - padding !== expectedBytes) return undefined;
|
|
52
|
+
const decoded = Buffer.from(value, "base64");
|
|
53
|
+
return decoded.toString("base64") === value ? decoded : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAvatarImage(value: unknown): value is IncarnateAvatarImageV1 {
|
|
57
|
+
if (!value || typeof value !== "object") return false;
|
|
58
|
+
const image = value as Record<string, unknown>;
|
|
59
|
+
if (!(
|
|
60
|
+
image.mimeType === "image/png" &&
|
|
61
|
+
typeof image.data === "string" &&
|
|
62
|
+
typeof image.widthPx === "number" &&
|
|
63
|
+
Number.isInteger(image.widthPx) &&
|
|
64
|
+
image.widthPx > 0 &&
|
|
65
|
+
image.widthPx <= AVATAR_PNG_MAX_DIMENSION &&
|
|
66
|
+
typeof image.heightPx === "number" &&
|
|
67
|
+
Number.isInteger(image.heightPx) &&
|
|
68
|
+
image.heightPx > 0 &&
|
|
69
|
+
image.heightPx <= AVATAR_PNG_MAX_DIMENSION &&
|
|
70
|
+
image.widthPx * image.heightPx <= AVATAR_PNG_MAX_PIXELS &&
|
|
71
|
+
typeof image.bytes === "number" &&
|
|
72
|
+
Number.isInteger(image.bytes) &&
|
|
73
|
+
image.bytes > 0 &&
|
|
74
|
+
image.bytes <= AVATAR_PNG_MAX_BYTES
|
|
75
|
+
)) return false;
|
|
76
|
+
const decoded = decodeCanonicalPng(image.data, image.bytes);
|
|
77
|
+
return Boolean(
|
|
78
|
+
decoded &&
|
|
79
|
+
decoded.byteLength >= 24 &&
|
|
80
|
+
decoded.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) &&
|
|
81
|
+
decoded.toString("ascii", 12, 16) === "IHDR" &&
|
|
82
|
+
decoded.readUInt32BE(16) === image.widthPx &&
|
|
83
|
+
decoded.readUInt32BE(20) === image.heightPx
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
29
87
|
export function isIncarnateUiStateV1(value: unknown): value is IncarnateUiStateV1 {
|
|
30
88
|
if (!value || typeof value !== "object") return false;
|
|
31
89
|
const candidate = value as Record<string, unknown>;
|
|
@@ -76,7 +134,8 @@ export function isIncarnateUiStateV1(value: unknown): value is IncarnateUiStateV
|
|
|
76
134
|
avatar.width < 0 ||
|
|
77
135
|
avatar.width > 48 ||
|
|
78
136
|
avatar.height !== avatar.lines.length ||
|
|
79
|
-
typeof avatar.truncated !== "boolean"
|
|
137
|
+
typeof avatar.truncated !== "boolean" ||
|
|
138
|
+
(avatar.image !== undefined && !isAvatarImage(avatar.image))
|
|
80
139
|
) {
|
|
81
140
|
return false;
|
|
82
141
|
}
|