pi-incarnate-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - Unreleased
4
+
5
+ - 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
+ - Add a real-entry integration gate against the sibling `pi-incarnate` workspace and run it in CI for both supported Node versions.
7
+ - Remove the unused `claim-v1` event; startup synchronization uses request-state and state events only.
8
+ - Prototype a standalone startup Header driven by the pi-incarnate UI protocol.
9
+ - Replace the scrolling startup dashboard with a fixed custom Footer capped at six rows.
10
+ - Generate a four-row portrait preview from the single sanitized avatar asset and collapse to three or two rows on constrained terminals.
11
+ - Preserve essential Pi runtime, context, model, directory, branch, cost, and extension status information in the replacement Footer.
12
+ - Add `/incarnate-ui` as a bounded, keyboard-dismissible overlay that shows every row of the active sanitized avatar.
13
+ - Leave Pi's default Footer untouched when no character is active, avatar presentation is off, or no compatible core state has arrived; acquire and release the custom Footer dynamically as state changes.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-incarnate-ui contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # pi-incarnate-ui
2
+
3
+ `pi-incarnate-ui` 是 [pi-incarnate](https://github.com/NandySun/pi-incarnate) 的可选固定终端界面。
4
+
5
+ 它使用 Pi 的公开 `setFooter()` API,把角色呈现和必要的 Pi 运行信息组合成固定底部区域:
6
+
7
+ - 固定区域严格不超过 6 行,避免压缩对话和输入框。
8
+ - 宽终端使用同一份头像自动生成 4 行纵向采样预览,旁边显示角色、mood、来源和表单状态;不要求额外维护缩略图。
9
+ - 终端窄于 72 列或低于 20 行时,自动降级为 1 行角色状态加 2 行运行信息。
10
+ - `off`、未启用角色或核心扩展没有响应时完全归还 Pi 默认 Footer,不占用空闲布局。
11
+ - 保留工作目录、Git 分支、session 名称、输入/输出与缓存 token、缓存命中率、cost、上下文占用、模型、provider、thinking 和其他扩展状态。
12
+ - Pi 默认启动 Header、对话、工具输出和输入框保持原样。
13
+ - `/incarnate-ui` 可按需打开完整头像浮层,Enter、Esc 或 `q` 关闭;浮层关闭后返回原输入界面。
14
+
15
+ 当前工作区是尚未发布的 `0.1.0` 本地候选;在用户明确授权前不发布 npm。首版不处理与其他自定义 Footer 扩展的共存。
16
+
17
+ ## Development
18
+
19
+ ```bash
20
+ npm install
21
+ npm run release:check
22
+ npm run test:companion
23
+ pi --no-extensions \
24
+ -e /path/to/pi-incarnate/extensions/index.ts \
25
+ -e /path/to/pi-incarnate-ui/extensions/index.ts
26
+ ```
27
+
28
+ `test:companion` 要求核心仓库位于同级目录 `/path/to/pi-incarnate`。它加载两个真实扩展入口,验证两种加载顺序、状态生命周期、未知协议版本和缺失另一端的降级。
29
+
30
+ 启动后通过 `/incarnate` 选择角色。核心扩展只管理人格、角色资源和安全清理;所有终端绘制均由本 UI 扩展负责。未加载 UI 时人格仍然生效,但不显示角色图。
31
+
32
+ 需要查看未经四行采样的完整头像时运行:
33
+
34
+ ```text
35
+ /incarnate-ui
36
+ ```
37
+
38
+ 查看器只在 TUI 中打开临时居中浮层,使用核心已经清理并限制为最多 48×16 的头像数据,不读取角色目录,也不改变 Footer 模式。终端高度不足时可用 `↑` / `↓` 滚动查看全部头像行。
39
+
40
+ ## 当前边界
41
+
42
+ - 只在 TUI 模式设置 Footer;RPC、JSON 和 print 模式不接管表现层。
43
+ - 角色状态由版本化的 `pi.events` 协议传递,UI 不直接读取角色目录。
44
+ - `compact` 使用 3 行布局;`auto` 和 `full` 在宽度足够时使用最多 6 行布局;`off` 或未激活状态不接管 Footer。
45
+ - 四行头像是从完整资源自动采样的常驻预览,不等同于完整原图;完整内容由按需查看器提供。
46
+ - 完整头像查看器是临时 overlay,不占用常驻布局;无活动头像或非 TUI 模式时只给出提示。
47
+ - 当前角色状态仍由 pi-incarnate 按 session 管理;新会话默认关闭,需要重新选择角色,此时显示 Pi 自身的默认 Footer。
48
+ - Pi 0.85.0 的公开扩展上下文不暴露通用订阅检测和 auto-compaction 开关;UI 只保留默认 Footer 可公开重建的信息,并沿用 Pi 对 `kimi-coding` 的已知订阅标记。其他 provider 的内部订阅状态与 auto-compaction 标记不显示。
package/SECURITY.md ADDED
@@ -0,0 +1,11 @@
1
+ # Security
2
+
3
+ Pi extensions run with the same permissions as Pi. Review extension source before loading it.
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.
6
+
7
+ The Footer renderer bounds every output line to the available terminal width, emits at most six rows, and accepts at most 16 avatar lines and 48 visible avatar columns. Character and runtime metadata is stripped of terminal control sequences before display. Unknown protocol versions and structurally invalid payloads are ignored.
8
+
9
+ The optional full-avatar overlay renders only the same bounded in-memory snapshot; it does not read files. Its title metadata is sanitized, its rows are clipped to the assigned overlay width, and it closes without changing character state.
10
+
11
+ 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.
@@ -0,0 +1,214 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ReadonlyFooterDataProvider,
5
+ Theme,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { TUI } from "@earendil-works/pi-tui";
8
+
9
+ import { AvatarViewerComponent } from "../src/avatar-viewer.ts";
10
+ import { renderFooter, type FooterSessionInfo, type FooterStyles } from "../src/layout.ts";
11
+ import {
12
+ isIncarnateUiStateV1,
13
+ UI_REQUEST_STATE_EVENT,
14
+ UI_STATE_EVENT,
15
+ type IncarnateUiStateV1,
16
+ } from "../src/protocol.ts";
17
+
18
+ const inactiveState: IncarnateUiStateV1 = { version: 1, active: false, avatarMode: "auto" };
19
+
20
+ function themeStyles(theme: Theme): FooterStyles {
21
+ return {
22
+ accent: (text) => theme.fg("accent", text),
23
+ muted: (text) => theme.fg("muted", text),
24
+ dim: (text) => theme.fg("dim", text),
25
+ text: (text) => theme.fg("text", text),
26
+ success: (text) => theme.fg("success", text),
27
+ warning: (text) => theme.fg("warning", text),
28
+ error: (text) => theme.fg("error", text),
29
+ };
30
+ }
31
+
32
+ function numberField(value: unknown): number {
33
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
34
+ }
35
+
36
+ function sessionUsage(
37
+ context: ExtensionContext | undefined,
38
+ ): Pick<
39
+ FooterSessionInfo,
40
+ "inputTokens" | "outputTokens" | "cacheReadTokens" | "cacheWriteTokens" | "cacheHitPercent" | "cost"
41
+ > {
42
+ let inputTokens = 0;
43
+ let outputTokens = 0;
44
+ let cacheReadTokens = 0;
45
+ let cacheWriteTokens = 0;
46
+ let cacheHitPercent: number | undefined;
47
+ let cost = 0;
48
+ for (const entry of context?.sessionManager.getEntries() ?? []) {
49
+ const record = entry as unknown as Record<string, unknown>;
50
+ let usageValue: unknown;
51
+ let assistantUsage = false;
52
+ if (entry.type === "message") {
53
+ const message = record.message as Record<string, unknown> | undefined;
54
+ if (message?.role !== "assistant" && message?.role !== "toolResult") continue;
55
+ usageValue = message.usage;
56
+ assistantUsage = message.role === "assistant";
57
+ } else if (entry.type === "branch_summary" || entry.type === "compaction") {
58
+ usageValue = record.usage;
59
+ } else {
60
+ continue;
61
+ }
62
+ if (!usageValue || typeof usageValue !== "object") continue;
63
+ const usage = usageValue as Record<string, unknown>;
64
+ inputTokens += numberField(usage.input);
65
+ outputTokens += numberField(usage.output);
66
+ cacheReadTokens += numberField(usage.cacheRead);
67
+ cacheWriteTokens += numberField(usage.cacheWrite);
68
+ if (assistantUsage) {
69
+ const promptTokens = numberField(usage.input) + numberField(usage.cacheRead) + numberField(usage.cacheWrite);
70
+ if (promptTokens > 0) cacheHitPercent = (numberField(usage.cacheRead) / promptTokens) * 100;
71
+ }
72
+ const usageCost = usage.cost;
73
+ if (usageCost && typeof usageCost === "object") cost += numberField((usageCost as Record<string, unknown>).total);
74
+ }
75
+ return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, cacheHitPercent, cost };
76
+ }
77
+
78
+ function sessionInfo(
79
+ context: ExtensionContext | undefined,
80
+ footerData: ReadonlyFooterDataProvider,
81
+ tui: TUI,
82
+ ): FooterSessionInfo {
83
+ const usage = context?.getContextUsage();
84
+ const model = context?.model;
85
+ return {
86
+ model: model?.id,
87
+ provider: model?.provider,
88
+ availableProviderCount: footerData.getAvailableProviderCount(),
89
+ supportsReasoning: model?.reasoning,
90
+ thinking: context?.thinkingLevel,
91
+ cwd: context?.cwd,
92
+ branch: footerData.getGitBranch(),
93
+ sessionName: context?.sessionManager.getSessionName(),
94
+ contextPercent: usage?.percent,
95
+ contextWindow: usage?.contextWindow ?? model?.contextWindow,
96
+ usingSubscription: model?.provider === "kimi-coding",
97
+ statuses: [...footerData.getExtensionStatuses().entries()]
98
+ .sort(([left], [right]) => left.localeCompare(right))
99
+ .map(([, value]) => value),
100
+ terminalRows: tui.terminal.rows,
101
+ ...sessionUsage(context),
102
+ };
103
+ }
104
+
105
+ export default function incarnateUiExtension(pi: ExtensionAPI): void {
106
+ let state = inactiveState;
107
+ let context: ExtensionContext | undefined;
108
+ let tui: TUI | undefined;
109
+ let footerInstalled = false;
110
+
111
+ const requestRender = (): void => tui?.requestRender();
112
+ const requestState = (): void => {
113
+ pi.events.emit(UI_REQUEST_STATE_EVENT, { version: 1 });
114
+ };
115
+
116
+ const shouldInstallFooter = (): boolean => Boolean(state.active && state.character && state.avatarMode !== "off");
117
+
118
+ const installFooter = (): void => {
119
+ if (!context || context.mode !== "tui" || footerInstalled) return;
120
+ context.ui.setFooter((footerTui, theme, footerData) => {
121
+ tui = footerTui;
122
+ const unsubscribe = footerData.onBranchChange(requestRender);
123
+ return {
124
+ render(width: number): string[] {
125
+ return renderFooter(width, state, sessionInfo(context, footerData, footerTui), themeStyles(theme));
126
+ },
127
+ invalidate() {},
128
+ dispose() {
129
+ unsubscribe();
130
+ if (tui === footerTui) tui = undefined;
131
+ },
132
+ };
133
+ });
134
+ footerInstalled = true;
135
+ };
136
+
137
+ const syncFooter = (): void => {
138
+ if (!context || context.mode !== "tui") return;
139
+ if (shouldInstallFooter()) {
140
+ if (footerInstalled) requestRender();
141
+ else installFooter();
142
+ return;
143
+ }
144
+ if (!footerInstalled) return;
145
+ context.ui.setFooter(undefined);
146
+ footerInstalled = false;
147
+ tui = undefined;
148
+ };
149
+
150
+ pi.events.on(UI_STATE_EVENT, (payload) => {
151
+ if (!isIncarnateUiStateV1(payload)) return;
152
+ state = payload;
153
+ syncFooter();
154
+ });
155
+
156
+ pi.registerCommand("incarnate-ui", {
157
+ description: "Show the active character's full avatar",
158
+ handler: async (args, ctx) => {
159
+ const action = args.trim();
160
+ if (action && action !== "avatar") {
161
+ ctx.ui.notify("Usage: /incarnate-ui [avatar]", "error");
162
+ return;
163
+ }
164
+ if (ctx.mode !== "tui") {
165
+ ctx.ui.notify("The full avatar viewer is available only in TUI mode", "warning");
166
+ return;
167
+ }
168
+ if (!state.active || !state.avatar?.lines.length) {
169
+ ctx.ui.notify("No active avatar. Enable a character and its avatar first.", "warning");
170
+ return;
171
+ }
172
+ await ctx.ui.custom<void>(
173
+ (overlayTui, theme, _keybindings, done) => new AvatarViewerComponent(state, theme, done, overlayTui),
174
+ {
175
+ overlay: true,
176
+ overlayOptions: {
177
+ anchor: "center",
178
+ width: Math.max(24, Math.min(54, state.avatar.width + 4)),
179
+ maxHeight: 20,
180
+ margin: 1,
181
+ },
182
+ },
183
+ );
184
+ },
185
+ });
186
+
187
+ pi.on("session_start", (_event, ctx) => {
188
+ if (footerInstalled && context?.mode === "tui") context.ui.setFooter(undefined);
189
+ footerInstalled = false;
190
+ tui = undefined;
191
+ state = inactiveState;
192
+ context = ctx;
193
+ if (ctx.mode !== "tui") return;
194
+ requestState();
195
+ });
196
+
197
+ pi.on("model_select", (_event, ctx) => {
198
+ context = ctx;
199
+ requestRender();
200
+ });
201
+
202
+ pi.on("thinking_level_select", (_event, ctx) => {
203
+ context = ctx;
204
+ requestRender();
205
+ });
206
+
207
+ pi.on("session_shutdown", (_event, ctx) => {
208
+ if (ctx.mode === "tui" && footerInstalled) ctx.ui.setFooter(undefined);
209
+ footerInstalled = false;
210
+ tui = undefined;
211
+ context = undefined;
212
+ state = inactiveState;
213
+ });
214
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "pi-incarnate-ui",
3
+ "version": "0.1.0",
4
+ "description": "A compact character-aware footer for pi-incarnate",
5
+ "author": "NandySun",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/NandySun/pi-incarnate-ui.git"
11
+ },
12
+ "homepage": "https://github.com/NandySun/pi-incarnate-ui#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/NandySun/pi-incarnate-ui/issues"
15
+ },
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-agent",
19
+ "pi-extension",
20
+ "pi-incarnate",
21
+ "tui",
22
+ "footer"
23
+ ],
24
+ "files": [
25
+ "CHANGELOG.md",
26
+ "LICENSE",
27
+ "README.md",
28
+ "SECURITY.md",
29
+ "extensions",
30
+ "src"
31
+ ],
32
+ "scripts": {
33
+ "check": "tsc --noEmit",
34
+ "test": "node --test tests/*.test.ts",
35
+ "test:companion": "npm --prefix ../pi-incarnate run test:companion",
36
+ "test:package": "node scripts/package-smoke.mjs",
37
+ "verify": "npm run check && npm test",
38
+ "release:check": "npm run verify && npm run test:package && npm pack --dry-run --ignore-scripts",
39
+ "prepublishOnly": "npm run verify"
40
+ },
41
+ "pi": {
42
+ "extensions": [
43
+ "./extensions/index.ts"
44
+ ]
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
49
+ },
50
+ "devDependencies": {
51
+ "@earendil-works/pi-coding-agent": "0.85.0",
52
+ "@earendil-works/pi-tui": "0.85.0",
53
+ "@types/node": "^24.0.0",
54
+ "typescript": "^5.9.3"
55
+ },
56
+ "engines": {
57
+ "node": ">=22.19.0"
58
+ }
59
+ }
@@ -0,0 +1,127 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, type Focusable, type TUI } from "@earendil-works/pi-tui";
3
+
4
+ import type { IncarnateUiStateV1 } from "./protocol.ts";
5
+
6
+ export interface AvatarViewerStyles {
7
+ border(text: string): string;
8
+ title(text: string): string;
9
+ dim(text: string): string;
10
+ }
11
+
12
+ export const plainAvatarViewerStyles: AvatarViewerStyles = {
13
+ border: (text) => text,
14
+ title: (text) => text,
15
+ dim: (text) => text,
16
+ };
17
+
18
+ function sanitizeInline(value: string): string {
19
+ return stripTerminalSequences(value)
20
+ .replace(/[\r\n\t\u0000-\u001f\u007f-\u009f]+/g, " ")
21
+ .replace(/\s+/g, " ")
22
+ .trim();
23
+ }
24
+
25
+ function pad(value: string, width: number): string {
26
+ const clipped = truncateToWidth(value, Math.max(0, width), "…");
27
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
28
+ }
29
+
30
+ function center(value: string, width: number): string {
31
+ const clipped = truncateToWidth(value, Math.max(0, width), "");
32
+ const available = Math.max(0, width - visibleWidth(clipped));
33
+ const left = Math.floor(available / 2);
34
+ return `${" ".repeat(left)}${clipped}${" ".repeat(available - left)}`;
35
+ }
36
+
37
+ export function renderAvatarViewer(
38
+ width: number,
39
+ state: IncarnateUiStateV1,
40
+ styles: AvatarViewerStyles = plainAvatarViewerStyles,
41
+ offset = 0,
42
+ maxAvatarRows = Number.POSITIVE_INFINITY,
43
+ ): string[] {
44
+ const safeWidth = Math.max(1, width);
45
+ if (safeWidth < 4) return [truncateToWidth("avatar", safeWidth, "")];
46
+ const innerWidth = safeWidth - 2;
47
+ if (!state.active || !state.character || !state.avatar?.lines.length) {
48
+ return [
49
+ styles.border(`╭${"─".repeat(innerWidth)}╮`),
50
+ `${styles.border("│")}${pad(styles.dim(" No active avatar"), innerWidth)}${styles.border("│")}`,
51
+ styles.border(`╰${"─".repeat(innerWidth)}╯`),
52
+ ];
53
+ }
54
+
55
+ const name = sanitizeInline(state.character.name) || "unknown";
56
+ const id = sanitizeInline(state.character.id) || "unknown";
57
+ const title = truncateToWidth(` ${name} / ${id} · full avatar `, innerWidth, "");
58
+ 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.lines.length - 1)));
60
+ const visibleLines = state.avatar.lines.slice(start, start + Math.max(1, Math.floor(maxAvatarRows)));
61
+ const avatarRows = visibleLines.map(
62
+ (line) => `${styles.border("│")}${center(line, innerWidth)}${styles.border("│")}`,
63
+ );
64
+ const range = visibleLines.length < state.avatar.lines.length
65
+ ? ` ${start + 1}-${start + visibleLines.length}/${state.avatar.lines.length} · ↑/↓ ·`
66
+ : "";
67
+ const help = `${styles.border("│")}${pad(styles.dim(`${range} Enter / Esc / q · close`), innerWidth)}${styles.border("│")}`;
68
+ const bottom = styles.border(`╰${"─".repeat(innerWidth)}╯`);
69
+ return [top, ...avatarRows, help, bottom];
70
+ }
71
+
72
+ function viewerStyles(theme: Theme): AvatarViewerStyles {
73
+ return {
74
+ border: (text) => theme.fg("border", text),
75
+ title: (text) => theme.fg("accent", text),
76
+ dim: (text) => theme.fg("dim", text),
77
+ };
78
+ }
79
+
80
+ export class AvatarViewerComponent implements Focusable {
81
+ focused = false;
82
+ private readonly state: IncarnateUiStateV1;
83
+ private readonly theme: Theme;
84
+ private readonly done: () => void;
85
+ private readonly tui: TUI | undefined;
86
+ private offset = 0;
87
+
88
+ constructor(state: IncarnateUiStateV1, theme: Theme, done: () => void, tui?: TUI) {
89
+ this.state = state;
90
+ this.theme = theme;
91
+ this.done = done;
92
+ this.tui = tui;
93
+ }
94
+
95
+ handleInput(data: string): void {
96
+ const avatarRows = this.state.avatar?.lines.length ?? 0;
97
+ const viewportRows = this.viewportRows();
98
+ if (matchesKey(data, "up")) {
99
+ this.offset = Math.max(0, this.offset - 1);
100
+ this.tui?.requestRender();
101
+ return;
102
+ }
103
+ if (matchesKey(data, "down")) {
104
+ this.offset = Math.min(Math.max(0, avatarRows - viewportRows), this.offset + 1);
105
+ this.tui?.requestRender();
106
+ return;
107
+ }
108
+ if (
109
+ matchesKey(data, "escape") ||
110
+ matchesKey(data, "return") ||
111
+ matchesKey(data, "ctrl+c") ||
112
+ matchesKey(data, "q")
113
+ ) {
114
+ this.done();
115
+ }
116
+ }
117
+
118
+ private viewportRows(): number {
119
+ return Math.max(1, (this.tui?.terminal.rows ?? 24) - 5);
120
+ }
121
+
122
+ render(width: number): string[] {
123
+ return renderAvatarViewer(width, this.state, viewerStyles(this.theme), this.offset, this.viewportRows());
124
+ }
125
+
126
+ invalidate(): void {}
127
+ }
package/src/layout.ts ADDED
@@ -0,0 +1,192 @@
1
+ import { basename } from "node:path";
2
+ import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ import type { IncarnateUiStateV1 } from "./protocol.ts";
5
+
6
+ export const MAX_FOOTER_ROWS = 6;
7
+ export const PORTRAIT_ROWS = 4;
8
+ const MIN_PORTRAIT_INFO_COLUMNS = 26;
9
+ const AUTO_PORTRAIT_MIN_COLUMNS = 72;
10
+ const AUTO_PORTRAIT_MIN_TERMINAL_ROWS = 20;
11
+
12
+ export interface FooterSessionInfo {
13
+ model?: string;
14
+ provider?: string;
15
+ availableProviderCount?: number;
16
+ supportsReasoning?: boolean;
17
+ thinking?: string;
18
+ cwd?: string;
19
+ branch?: string | null;
20
+ sessionName?: string;
21
+ contextPercent?: number | null;
22
+ contextWindow?: number;
23
+ inputTokens?: number;
24
+ outputTokens?: number;
25
+ cacheReadTokens?: number;
26
+ cacheWriteTokens?: number;
27
+ cacheHitPercent?: number;
28
+ cost?: number;
29
+ usingSubscription?: boolean;
30
+ statuses?: string[];
31
+ terminalRows?: number;
32
+ }
33
+
34
+ export interface FooterStyles {
35
+ accent(text: string): string;
36
+ muted(text: string): string;
37
+ dim(text: string): string;
38
+ text(text: string): string;
39
+ success(text: string): string;
40
+ warning(text: string): string;
41
+ error(text: string): string;
42
+ }
43
+
44
+ export const plainFooterStyles: FooterStyles = {
45
+ accent: (text) => text,
46
+ muted: (text) => text,
47
+ dim: (text) => text,
48
+ text: (text) => text,
49
+ success: (text) => text,
50
+ warning: (text) => text,
51
+ error: (text) => text,
52
+ };
53
+
54
+ function sanitizeInline(value: string): string {
55
+ return stripTerminalSequences(value)
56
+ .replace(/[\r\n\t\u0000-\u001f\u007f-\u009f]+/g, " ")
57
+ .replace(/\s+/g, " ")
58
+ .trim();
59
+ }
60
+
61
+ function padToWidth(value: string, width: number): string {
62
+ const clipped = truncateToWidth(value, Math.max(0, width), "…");
63
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
64
+ }
65
+
66
+ function joinSides(left: string, right: string, width: number): string {
67
+ if (width <= 0) return "";
68
+ const safeLeft = truncateToWidth(left, width, "…");
69
+ const availableRight = width - visibleWidth(safeLeft) - 2;
70
+ if (availableRight <= 0) return padToWidth(safeLeft, width);
71
+ const safeRight = truncateToWidth(right, availableRight, "");
72
+ const gap = Math.max(2, width - visibleWidth(safeLeft) - visibleWidth(safeRight));
73
+ return padToWidth(`${safeLeft}${" ".repeat(gap)}${safeRight}`, width);
74
+ }
75
+
76
+ function formatTokens(value: number | undefined): string {
77
+ const count = Math.max(0, value ?? 0);
78
+ if (count < 1_000) return String(Math.round(count));
79
+ if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`;
80
+ if (count < 1_000_000) return `${Math.round(count / 1_000)}k`;
81
+ return `${(count / 1_000_000).toFixed(count < 10_000_000 ? 1 : 0)}M`;
82
+ }
83
+
84
+ function systemRows(width: number, session: FooterSessionInfo, styles: FooterStyles): string[] {
85
+ const cwd = sanitizeInline(session.cwd ? basename(session.cwd) || session.cwd : "") || "unknown";
86
+ const safeBranch = sanitizeInline(session.branch ?? "");
87
+ const branch = safeBranch ? ` (${safeBranch})` : "";
88
+ const safeSessionName = sanitizeInline(session.sessionName ?? "");
89
+ const sessionName = safeSessionName ? ` · ${safeSessionName}` : "";
90
+ const status = (session.statuses ?? []).map(sanitizeInline).filter(Boolean).join(" · ");
91
+ const location = `${cwd}${branch}${sessionName}${status ? ` · ${status}` : ""}`;
92
+
93
+ const usage: string[] = [];
94
+ if (session.inputTokens) usage.push(`↑${formatTokens(session.inputTokens)}`);
95
+ if (session.outputTokens) usage.push(`↓${formatTokens(session.outputTokens)}`);
96
+ if (session.cacheReadTokens) usage.push(`R${formatTokens(session.cacheReadTokens)}`);
97
+ if (session.cacheWriteTokens) usage.push(`W${formatTokens(session.cacheWriteTokens)}`);
98
+ if (session.cacheHitPercent !== undefined) usage.push(`CH${session.cacheHitPercent.toFixed(1)}%`);
99
+ if (session.cost || session.usingSubscription) {
100
+ usage.push(`$${(session.cost ?? 0).toFixed(3)}${session.usingSubscription ? " (sub)" : ""}`);
101
+ }
102
+ const contextText = session.contextPercent === null || session.contextPercent === undefined
103
+ ? `ctx ?/${formatTokens(session.contextWindow)}`
104
+ : `ctx ${session.contextPercent.toFixed(1)}%/${formatTokens(session.contextWindow)}`;
105
+ const context = session.contextPercent !== null && session.contextPercent !== undefined && session.contextPercent > 90
106
+ ? styles.error(contextText)
107
+ : session.contextPercent !== null && session.contextPercent !== undefined && session.contextPercent > 70
108
+ ? styles.warning(contextText)
109
+ : styles.dim(contextText);
110
+ const usageText = `${usage.length > 0 ? `${styles.dim(usage.join(" "))} ` : ""}${context}`;
111
+ const model = sanitizeInline(session.model ?? "") || "no-model";
112
+ const thinking = sanitizeInline(session.thinking ?? "");
113
+ const provider = sanitizeInline(session.provider ?? "");
114
+ const providerPrefix = provider && (session.availableProviderCount ?? 0) > 1 ? `(${provider}) ` : "";
115
+ const runtime = `${providerPrefix}${model}${session.supportsReasoning && thinking ? ` · think:${thinking}` : ""}`;
116
+
117
+ return [
118
+ padToWidth(styles.dim(location), width),
119
+ joinSides(usageText, styles.dim(runtime), width),
120
+ ];
121
+ }
122
+
123
+ export function sampleAvatarLines(lines: string[], rows = PORTRAIT_ROWS): string[] {
124
+ if (rows <= 0 || lines.length === 0) return [];
125
+ if (lines.length <= rows) return [...lines, ...Array.from({ length: rows - lines.length }, () => "")];
126
+ if (rows === 1) return [lines[Math.floor((lines.length - 1) / 2)] ?? ""];
127
+ return Array.from({ length: rows }, (_, index) => {
128
+ const sourceIndex = Math.round((index * (lines.length - 1)) / (rows - 1));
129
+ return lines[sourceIndex] ?? "";
130
+ });
131
+ }
132
+
133
+ function portraitRows(
134
+ width: number,
135
+ state: IncarnateUiStateV1,
136
+ session: FooterSessionInfo,
137
+ styles: FooterStyles,
138
+ ): string[] | undefined {
139
+ if (!state.active || !state.character || !state.avatar?.lines.length) return undefined;
140
+ if (state.avatarMode === "off" || state.avatarMode === "compact") return undefined;
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
+ }
147
+
148
+ const avatarWidth = Math.min(state.avatar.width, width);
149
+ const infoWidth = width - avatarWidth - 3;
150
+ if (avatarWidth <= 0 || infoWidth < MIN_PORTRAIT_INFO_COLUMNS) return undefined;
151
+
152
+ const character = state.character;
153
+ const name = sanitizeInline(character.name) || "unknown";
154
+ const id = sanitizeInline(character.id) || "unknown";
155
+ const mood = sanitizeInline(character.mood ?? "") || "none";
156
+ const info = [
157
+ `${styles.success("●")} ${styles.accent(name)} ${styles.dim(`/ ${id}`)}`,
158
+ `${styles.muted("mood")} ${styles.text(mood)}`,
159
+ `${styles.muted("forms")} ${styles.text(`${character.forms.available}/${character.forms.total}`)} ${styles.dim(`· ${character.source}`)}`,
160
+ styles.dim("/incarnate · configure"),
161
+ ];
162
+ return sampleAvatarLines(state.avatar.lines).map((line, index) => {
163
+ const avatar = padToWidth(line, avatarWidth);
164
+ const details = padToWidth(info[index] ?? "", infoWidth);
165
+ return `${avatar} ${styles.accent("│")} ${details}`;
166
+ });
167
+ }
168
+
169
+ function compactCharacterRow(width: number, state: IncarnateUiStateV1, styles: FooterStyles): string[] {
170
+ if (!state.active || !state.character || state.avatarMode === "off") return [];
171
+ const name = sanitizeInline(state.character.name) || "unknown";
172
+ const id = sanitizeInline(state.character.id) || "unknown";
173
+ const safeMood = sanitizeInline(state.character.mood ?? "");
174
+ const mood = safeMood ? ` · mood:${safeMood}` : "";
175
+ return [
176
+ padToWidth(
177
+ `${styles.success("●")} ${styles.accent(name)} ${styles.dim(`/ ${id}${mood}`)}`,
178
+ width,
179
+ ),
180
+ ];
181
+ }
182
+
183
+ export function renderFooter(
184
+ width: number,
185
+ state: IncarnateUiStateV1,
186
+ session: FooterSessionInfo = {},
187
+ styles: FooterStyles = plainFooterStyles,
188
+ ): string[] {
189
+ const safeWidth = Math.max(1, width);
190
+ const character = portraitRows(safeWidth, state, session, styles) ?? compactCharacterRow(safeWidth, state, styles);
191
+ return [...character, ...systemRows(safeWidth, session, styles)].slice(0, MAX_FOOTER_ROWS);
192
+ }
@@ -0,0 +1,85 @@
1
+ export const UI_REQUEST_STATE_EVENT = "pi-incarnate:ui:request-state-v1";
2
+ export const UI_STATE_EVENT = "pi-incarnate:ui:state-v1";
3
+
4
+ export type CharacterSource = "built-in" | "personal";
5
+ export type AvatarMode = "auto" | "full" | "compact" | "off";
6
+
7
+ export interface IncarnateUiStateV1 {
8
+ version: 1;
9
+ active: boolean;
10
+ avatarMode: AvatarMode;
11
+ character?: {
12
+ id: string;
13
+ name: string;
14
+ mood?: string;
15
+ source: CharacterSource;
16
+ forms: {
17
+ available: number;
18
+ total: number;
19
+ };
20
+ };
21
+ avatar?: {
22
+ lines: string[];
23
+ width: number;
24
+ height: number;
25
+ truncated: boolean;
26
+ };
27
+ }
28
+
29
+ export function isIncarnateUiStateV1(value: unknown): value is IncarnateUiStateV1 {
30
+ if (!value || typeof value !== "object") return false;
31
+ const candidate = value as Record<string, unknown>;
32
+ if (
33
+ candidate.version !== 1 ||
34
+ typeof candidate.active !== "boolean" ||
35
+ typeof candidate.avatarMode !== "string" ||
36
+ !["auto", "full", "compact", "off"].includes(candidate.avatarMode)
37
+ ) {
38
+ return false;
39
+ }
40
+
41
+ if (candidate.active) {
42
+ if (!candidate.character || typeof candidate.character !== "object") return false;
43
+ const character = candidate.character as Record<string, unknown>;
44
+ if (
45
+ typeof character.id !== "string" ||
46
+ typeof character.name !== "string" ||
47
+ (character.mood !== undefined && typeof character.mood !== "string") ||
48
+ (character.source !== "built-in" && character.source !== "personal") ||
49
+ !character.forms ||
50
+ typeof character.forms !== "object"
51
+ ) {
52
+ return false;
53
+ }
54
+ const forms = character.forms as Record<string, unknown>;
55
+ if (
56
+ typeof forms.available !== "number" ||
57
+ !Number.isInteger(forms.available) ||
58
+ forms.available < 0 ||
59
+ typeof forms.total !== "number" ||
60
+ !Number.isInteger(forms.total) ||
61
+ forms.total < forms.available
62
+ ) {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ if (candidate.avatar !== undefined) {
68
+ if (!candidate.avatar || typeof candidate.avatar !== "object") return false;
69
+ const avatar = candidate.avatar as Record<string, unknown>;
70
+ if (
71
+ !Array.isArray(avatar.lines) ||
72
+ avatar.lines.length > 16 ||
73
+ !avatar.lines.every((line) => typeof line === "string") ||
74
+ typeof avatar.width !== "number" ||
75
+ !Number.isInteger(avatar.width) ||
76
+ avatar.width < 0 ||
77
+ avatar.width > 48 ||
78
+ avatar.height !== avatar.lines.length ||
79
+ typeof avatar.truncated !== "boolean"
80
+ ) {
81
+ return false;
82
+ }
83
+ }
84
+ return true;
85
+ }