pi-sprite 1.0.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 +12 -0
- package/LICENSE +21 -0
- package/NOTICE.md +7 -0
- package/README.md +353 -0
- package/examples/README.md +15 -0
- package/examples/custom-pets/wumpus-template/README.md +21 -0
- package/examples/custom-pets/wumpus-template/pet.json +14 -0
- package/examples/petdex-downloads/.gitkeep +0 -0
- package/extensions/index.ts +104 -0
- package/package.json +77 -0
- package/skills/pi-sprite-authoring/SKILL.md +330 -0
- package/skills/pi-sprite-authoring/assets/wumpus-template/pet.json +14 -0
- package/skills/pi-sprite-authoring/references/character-cohesion-review.md +65 -0
- package/skills/pi-sprite-authoring/references/gpt-image-sprite-workflow.md +181 -0
- package/skills/pi-sprite-authoring/references/petdex-reference-to-custom-pet.md +155 -0
- package/skills/pi-sprite-authoring/references/wumpus-sprite-prompts.md +54 -0
- package/skills/pi-sprite-authoring/scripts/assemble_sprite_strip.py +98 -0
- package/skills/pi-sprite-authoring/scripts/create-pet-template.mjs +51 -0
- package/skills/pi-sprite-authoring/scripts/create_motion_strip.py +110 -0
- package/skills/pi-sprite-authoring/scripts/download-petdex-examples.mjs +79 -0
- package/skills/pi-sprite-authoring/scripts/openai_sprite_image.py +223 -0
- package/skills/pi-sprite-authoring/scripts/remove_sprite_background.py +207 -0
- package/src/agent/session-entries.ts +28 -0
- package/src/agent/side-completion.ts +94 -0
- package/src/agent/side-session-text.ts +20 -0
- package/src/agent/side-session-types.ts +12 -0
- package/src/agent/side-session.ts +107 -0
- package/src/btw/completion.ts +15 -0
- package/src/btw/format.ts +30 -0
- package/src/btw/index.ts +306 -0
- package/src/btw/prompt.ts +35 -0
- package/src/btw/recap.ts +56 -0
- package/src/btw/session.ts +37 -0
- package/src/btw/thread-store.ts +37 -0
- package/src/context/index.ts +343 -0
- package/src/recap/conversation.ts +22 -0
- package/src/recap/direct.ts +15 -0
- package/src/recap/format.ts +25 -0
- package/src/recap/generation.ts +58 -0
- package/src/recap/index.ts +86 -0
- package/src/sprite/commands.ts +205 -0
- package/src/sprite/download.ts +75 -0
- package/src/sprite/kitty-placeholder.ts +441 -0
- package/src/sprite/live-status-format.ts +67 -0
- package/src/sprite/live-status.ts +48 -0
- package/src/sprite/loader.ts +134 -0
- package/src/sprite/manifest.ts +87 -0
- package/src/sprite/paths.ts +8 -0
- package/src/sprite/petdex.ts +133 -0
- package/src/sprite/renderer.ts +491 -0
- package/src/sprite/runtime.ts +524 -0
- package/src/sprite/turn-status-format.ts +112 -0
- package/src/sprite/turn-status.ts +88 -0
- package/src/ui/overlay.ts +308 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { completeWithApiKeyText } from "../agent/side-completion.ts";
|
|
3
|
+
import type { SideCompletionRequest, SideCompletionResult } from "../agent/side-session-types.ts";
|
|
4
|
+
import {
|
|
5
|
+
extractTextContent,
|
|
6
|
+
parseTurnStatusResponse,
|
|
7
|
+
recentConversationForTurnStatus,
|
|
8
|
+
type TurnStatus,
|
|
9
|
+
} from "./turn-status-format.ts";
|
|
10
|
+
|
|
11
|
+
const TURN_STATUS_MAX_TOKENS = 180;
|
|
12
|
+
const TURN_STATUS_TIMEOUT_MS = 20_000;
|
|
13
|
+
|
|
14
|
+
function finalAssistantText(messages: unknown[]): string {
|
|
15
|
+
for (const message of [...messages].reverse()) {
|
|
16
|
+
if (!message || typeof message !== "object") continue;
|
|
17
|
+
const typed = message as { role?: string; content?: unknown };
|
|
18
|
+
if (typed.role !== "assistant") continue;
|
|
19
|
+
const text = extractTextContent(typed.content).trim();
|
|
20
|
+
if (text) return text;
|
|
21
|
+
}
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type SideSessionAdapter = (ctx: ExtensionContext, request: SideCompletionRequest) => Promise<SideCompletionResult>;
|
|
26
|
+
type DirectCompletionAdapter = (ctx: ExtensionContext, prompt: string) => Promise<string | undefined>;
|
|
27
|
+
|
|
28
|
+
type TurnStatusAdapters = {
|
|
29
|
+
sideSession: SideSessionAdapter;
|
|
30
|
+
direct: DirectCompletionAdapter;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function promptForTurnStatus(recentConversation: string, finalResponse: string): string {
|
|
34
|
+
return [
|
|
35
|
+
"Classify the final state of this Pi coding-agent turn for a compact sprite footer.",
|
|
36
|
+
"Use the recent conversation to understand the user's actual goal; do not judge from only the last sentence.",
|
|
37
|
+
"Return JSON only with this shape:",
|
|
38
|
+
'{"state":"done|followup|blocked","label":"short footer label","detail":"optional one sentence","actions":["optional action"]}',
|
|
39
|
+
"Guidelines:",
|
|
40
|
+
'- state="done" when the requested work appears complete, including successful smoke checks, demos, or trigger actions.',
|
|
41
|
+
'- state="followup" only when a specific non-routine verification/action remains before the work is actually complete.',
|
|
42
|
+
'- Do not use state="followup" merely because the user may inspect, review, try, or check the completed work.',
|
|
43
|
+
'- state="blocked" only when user input is required before progress can continue.',
|
|
44
|
+
"- label should be concrete and footer-friendly, around 2-8 words.",
|
|
45
|
+
"- detail is optional and should be one sentence.",
|
|
46
|
+
"- actions is optional, max 3 short items.",
|
|
47
|
+
"",
|
|
48
|
+
"Recent conversation:",
|
|
49
|
+
recentConversation || "(No recent conversation available.)",
|
|
50
|
+
"",
|
|
51
|
+
"Final assistant response:",
|
|
52
|
+
finalResponse || "(No final assistant response available.)",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function directTurnStatusCompletion(ctx: ExtensionContext, prompt: string): Promise<string | undefined> {
|
|
57
|
+
const result = await completeWithApiKeyText(ctx, prompt, {
|
|
58
|
+
maxTokens: TURN_STATUS_MAX_TOKENS,
|
|
59
|
+
timeoutMs: TURN_STATUS_TIMEOUT_MS,
|
|
60
|
+
});
|
|
61
|
+
return result.ok ? result.text : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultAdapters: TurnStatusAdapters = {
|
|
65
|
+
sideSession: async (ctx, request) => {
|
|
66
|
+
const { completeWithSideSession } = await import("../agent/side-session.ts");
|
|
67
|
+
return await completeWithSideSession(ctx, request);
|
|
68
|
+
},
|
|
69
|
+
direct: directTurnStatusCompletion,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export async function classifyTurnStatus(
|
|
73
|
+
ctx: ExtensionContext,
|
|
74
|
+
messages: unknown[],
|
|
75
|
+
adapters: TurnStatusAdapters = defaultAdapters,
|
|
76
|
+
): Promise<TurnStatus | undefined> {
|
|
77
|
+
const recentConversation = recentConversationForTurnStatus(ctx.sessionManager.getBranch() as Iterable<unknown>);
|
|
78
|
+
const finalResponse = finalAssistantText(messages);
|
|
79
|
+
const prompt = promptForTurnStatus(recentConversation, finalResponse);
|
|
80
|
+
const sideResult = await adapters.sideSession(ctx, {
|
|
81
|
+
prompt,
|
|
82
|
+
maxTokens: TURN_STATUS_MAX_TOKENS,
|
|
83
|
+
timeoutMs: TURN_STATUS_TIMEOUT_MS,
|
|
84
|
+
});
|
|
85
|
+
if (sideResult.ok) return parseTurnStatusResponse(sideResult.text);
|
|
86
|
+
const directText = await adapters.direct(ctx, prompt);
|
|
87
|
+
return directText ? parseTurnStatusResponse(directText) : undefined;
|
|
88
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Component,
|
|
3
|
+
decodeKittyPrintable,
|
|
4
|
+
Key,
|
|
5
|
+
matchesKey,
|
|
6
|
+
type OverlayAnchor,
|
|
7
|
+
type OverlayMargin,
|
|
8
|
+
truncateToWidth,
|
|
9
|
+
visibleWidth,
|
|
10
|
+
wrapTextWithAnsi,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
|
|
13
|
+
interface ThemeLike {
|
|
14
|
+
fg(
|
|
15
|
+
color: "accent" | "border" | "borderMuted" | "success" | "error" | "warning" | "muted" | "dim" | "text",
|
|
16
|
+
text: string,
|
|
17
|
+
): string;
|
|
18
|
+
bold(text: string): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OverlaySection {
|
|
22
|
+
title?: string;
|
|
23
|
+
body: string;
|
|
24
|
+
accent?: "accent" | "success" | "warning" | "error" | "muted";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type BubbleTail = "none" | "bottom-left" | "bottom-right";
|
|
28
|
+
|
|
29
|
+
export interface SpriteBubblePlacement {
|
|
30
|
+
anchor: OverlayAnchor;
|
|
31
|
+
tail: BubbleTail;
|
|
32
|
+
margin: OverlayMargin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SpeechBubbleOptions {
|
|
36
|
+
tail?: BubbleTail;
|
|
37
|
+
scroll?: number;
|
|
38
|
+
maxBodyLines?: number;
|
|
39
|
+
maxWidth?: number;
|
|
40
|
+
minWidth?: number;
|
|
41
|
+
footerRows?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ReplyableSpeechBubbleOptions extends SpeechBubbleOptions {
|
|
45
|
+
requestRender?: () => void;
|
|
46
|
+
onSubmit: (text: string) => Promise<OverlaySection[]>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RenderedSpeechBubble {
|
|
50
|
+
lines: string[];
|
|
51
|
+
maxScroll: number;
|
|
52
|
+
bodyLineCount: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pad(line: string, width: number): string {
|
|
56
|
+
return `${line}${" ".repeat(Math.max(0, width - visibleWidth(line)))}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function contentRow(line: string, contentWidth: number, theme: ThemeLike): string {
|
|
60
|
+
return `${theme.fg("borderMuted", "│")} ${pad(truncateToWidth(line, contentWidth), contentWidth)} ${theme.fg("borderMuted", "│")}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dimensions(width: number, options: SpeechBubbleOptions = {}): { totalWidth: number; contentWidth: number } {
|
|
64
|
+
const minWidth = options.minWidth ?? 36;
|
|
65
|
+
const maxWidth = options.maxWidth ?? 92;
|
|
66
|
+
const totalWidth = Math.max(minWidth, Math.min(maxWidth, width - 4));
|
|
67
|
+
return { totalWidth, contentWidth: totalWidth - 4 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function topBorder(title: string, totalWidth: number, theme: ThemeLike): string {
|
|
71
|
+
const titleText = ` ${title} `;
|
|
72
|
+
const insideWidth = totalWidth - 2;
|
|
73
|
+
const titleWidth = visibleWidth(titleText);
|
|
74
|
+
return `${theme.fg("border", "╭─")}${theme.fg("accent", theme.bold(titleText))}${theme.fg("border", "─".repeat(Math.max(0, insideWidth - titleWidth - 1)))}${theme.fg("border", "╮")}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function bottomBorder(totalWidth: number, tail: BubbleTail, theme: ThemeLike): string[] {
|
|
78
|
+
const insideWidth = totalWidth - 2;
|
|
79
|
+
if (tail === "bottom-right") {
|
|
80
|
+
return [
|
|
81
|
+
`${theme.fg("border", "╰")}${theme.fg("border", "─".repeat(Math.max(0, insideWidth - 2)))}${theme.fg("border", "╰─╮")}`,
|
|
82
|
+
];
|
|
83
|
+
}
|
|
84
|
+
if (tail === "bottom-left") {
|
|
85
|
+
return [
|
|
86
|
+
`${theme.fg("border", "╭─╯")}${theme.fg("border", "─".repeat(Math.max(0, insideWidth - 2)))}${theme.fg("border", "╯")}`,
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
return [`${theme.fg("border", "╰")}${theme.fg("border", "─".repeat(insideWidth))}${theme.fg("border", "╯")}`];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sectionRows(sections: OverlaySection[], contentWidth: number, theme: ThemeLike): string[] {
|
|
93
|
+
const rows: string[] = [];
|
|
94
|
+
for (const [index, section] of sections.entries()) {
|
|
95
|
+
if (index > 0) rows.push(contentRow("", contentWidth, theme));
|
|
96
|
+
if (section.title)
|
|
97
|
+
rows.push(contentRow(theme.fg(section.accent ?? "accent", theme.bold(section.title)), contentWidth, theme));
|
|
98
|
+
const wrapped = wrapTextWithAnsi(section.body.trim() || "—", contentWidth);
|
|
99
|
+
for (const line of wrapped) rows.push(contentRow(line, contentWidth, theme));
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function scrollHint(hints: string, scroll: number, visibleLines: number, bodyLineCount: number): string {
|
|
105
|
+
if (bodyLineCount <= visibleLines) return hints;
|
|
106
|
+
const start = Math.min(bodyLineCount, scroll + 1);
|
|
107
|
+
const end = Math.min(bodyLineCount, scroll + visibleLines);
|
|
108
|
+
return `↑/↓ scroll ${start}-${end}/${bodyLineCount} · ${hints}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderOverlay(
|
|
112
|
+
title: string,
|
|
113
|
+
sections: OverlaySection[],
|
|
114
|
+
hints: string,
|
|
115
|
+
width: number,
|
|
116
|
+
theme: ThemeLike,
|
|
117
|
+
): string[] {
|
|
118
|
+
return renderSpeechBubble(title, sections, hints, width, theme, { tail: "none" }).lines;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function renderSpeechBubble(
|
|
122
|
+
title: string,
|
|
123
|
+
sections: OverlaySection[],
|
|
124
|
+
hints: string,
|
|
125
|
+
width: number,
|
|
126
|
+
theme: ThemeLike,
|
|
127
|
+
options: SpeechBubbleOptions = {},
|
|
128
|
+
): RenderedSpeechBubble {
|
|
129
|
+
const { totalWidth, contentWidth } = dimensions(width, options);
|
|
130
|
+
const bodyRows = sectionRows(sections, contentWidth, theme);
|
|
131
|
+
const maxBodyLines = options.maxBodyLines ?? bodyRows.length;
|
|
132
|
+
const maxScroll = Math.max(0, bodyRows.length - maxBodyLines);
|
|
133
|
+
const scroll = Math.max(0, Math.min(options.scroll ?? 0, maxScroll));
|
|
134
|
+
const visibleBodyRows = bodyRows.slice(scroll, scroll + maxBodyLines);
|
|
135
|
+
const lines = [topBorder(title, totalWidth, theme), ...visibleBodyRows];
|
|
136
|
+
for (const row of options.footerRows ?? []) lines.push(contentRow(row, contentWidth, theme));
|
|
137
|
+
lines.push(
|
|
138
|
+
contentRow(theme.fg("dim", scrollHint(hints, scroll, maxBodyLines, bodyRows.length)), contentWidth, theme),
|
|
139
|
+
);
|
|
140
|
+
lines.push(...bottomBorder(totalWidth, options.tail ?? "none", theme));
|
|
141
|
+
return { lines, maxScroll, bodyLineCount: bodyRows.length };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createScrollableSpeechBubble(
|
|
145
|
+
title: string,
|
|
146
|
+
sections: OverlaySection[],
|
|
147
|
+
hints: string,
|
|
148
|
+
theme: ThemeLike,
|
|
149
|
+
done: (result: undefined) => void,
|
|
150
|
+
options: SpeechBubbleOptions = {},
|
|
151
|
+
): Component {
|
|
152
|
+
let scroll = 0;
|
|
153
|
+
let maxScroll = 0;
|
|
154
|
+
const pageSize = () => Math.max(1, (options.maxBodyLines ?? 12) - 2);
|
|
155
|
+
const clamp = () => {
|
|
156
|
+
scroll = Math.max(0, Math.min(scroll, maxScroll));
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
render: (width: number) => {
|
|
160
|
+
const rendered = renderSpeechBubble(title, sections, hints, width, theme, { ...options, scroll });
|
|
161
|
+
maxScroll = rendered.maxScroll;
|
|
162
|
+
clamp();
|
|
163
|
+
return rendered.lines;
|
|
164
|
+
},
|
|
165
|
+
invalidate: () => {},
|
|
166
|
+
handleInput: (data: string) => {
|
|
167
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || matchesKey(data, "escape")) {
|
|
168
|
+
done(undefined);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) scroll--;
|
|
172
|
+
else if (matchesKey(data, "down") || matchesKey(data, "j")) scroll++;
|
|
173
|
+
else if (matchesKey(data, "pageUp") || matchesKey(data, "u")) scroll -= pageSize();
|
|
174
|
+
else if (matchesKey(data, "pageDown") || matchesKey(data, "d") || matchesKey(data, "space")) scroll += pageSize();
|
|
175
|
+
else if (matchesKey(data, "g")) scroll = 0;
|
|
176
|
+
else if (matchesKey(data, Key.shift("g"))) scroll = maxScroll;
|
|
177
|
+
clamp();
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inputBoxRows(draft: string, busy: boolean, contentWidth: number, theme: ThemeLike): string[] {
|
|
183
|
+
const label = busy ? " Thinking " : " Reply ";
|
|
184
|
+
const topPrefix = `╭─${label}`;
|
|
185
|
+
const top = `${topPrefix}${"─".repeat(Math.max(0, contentWidth - visibleWidth(topPrefix) - 1))}╮`;
|
|
186
|
+
const value = busy
|
|
187
|
+
? theme.fg("muted", "Thinking…")
|
|
188
|
+
: draft
|
|
189
|
+
? theme.fg("text", draft)
|
|
190
|
+
: theme.fg("dim", "Type a follow-up…");
|
|
191
|
+
const inputWidth = Math.max(1, contentWidth - 4);
|
|
192
|
+
const input = `│ ${pad(truncateToWidth(`${value}${busy ? "" : "▌"}`, inputWidth), inputWidth)} │`;
|
|
193
|
+
const bottom = `╰${"─".repeat(Math.max(0, contentWidth - 2))}╯`;
|
|
194
|
+
return [theme.fg("borderMuted", top), input, theme.fg("borderMuted", bottom)];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function printableInput(data: string): string {
|
|
198
|
+
const decoded = decodeKittyPrintable(data);
|
|
199
|
+
if (decoded !== undefined) return decoded;
|
|
200
|
+
const pasteText =
|
|
201
|
+
data.includes("\u001b[200~") || data.includes("\u001b[201~")
|
|
202
|
+
? data.replaceAll("\u001b[200~", "").replaceAll("\u001b[201~", "")
|
|
203
|
+
: data;
|
|
204
|
+
if (pasteText.includes("\u001b")) return "";
|
|
205
|
+
return Array.from(pasteText)
|
|
206
|
+
.filter((char) => {
|
|
207
|
+
const code = char.codePointAt(0) ?? 0;
|
|
208
|
+
return code >= 32 && code !== 127 && !(code >= 0x80 && code <= 0x9f);
|
|
209
|
+
})
|
|
210
|
+
.join("");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function createReplyableSpeechBubble(
|
|
214
|
+
title: string,
|
|
215
|
+
initialSections: OverlaySection[],
|
|
216
|
+
theme: ThemeLike,
|
|
217
|
+
done: (result: undefined) => void,
|
|
218
|
+
options: ReplyableSpeechBubbleOptions,
|
|
219
|
+
): Component {
|
|
220
|
+
let sections = initialSections;
|
|
221
|
+
let draft = "";
|
|
222
|
+
let scroll = 0;
|
|
223
|
+
let maxScroll = 0;
|
|
224
|
+
let busy = false;
|
|
225
|
+
let error: string | undefined;
|
|
226
|
+
let scrollToBottom = true;
|
|
227
|
+
const pageSize = () => Math.max(1, (options.maxBodyLines ?? 12) - 2);
|
|
228
|
+
const clamp = () => {
|
|
229
|
+
scroll = Math.max(0, Math.min(scroll, maxScroll));
|
|
230
|
+
};
|
|
231
|
+
const refresh = () => {
|
|
232
|
+
options.requestRender?.();
|
|
233
|
+
};
|
|
234
|
+
const submit = async () => {
|
|
235
|
+
const text = draft.trim();
|
|
236
|
+
if (!text || busy) return;
|
|
237
|
+
draft = "";
|
|
238
|
+
busy = true;
|
|
239
|
+
error = undefined;
|
|
240
|
+
scrollToBottom = true;
|
|
241
|
+
refresh();
|
|
242
|
+
try {
|
|
243
|
+
sections = await options.onSubmit(text);
|
|
244
|
+
} catch (err) {
|
|
245
|
+
error = err instanceof Error ? err.message : "Reply failed.";
|
|
246
|
+
} finally {
|
|
247
|
+
busy = false;
|
|
248
|
+
scrollToBottom = true;
|
|
249
|
+
refresh();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
return {
|
|
253
|
+
render: (width: number) => {
|
|
254
|
+
const displaySections = [...sections];
|
|
255
|
+
if (busy)
|
|
256
|
+
displaySections.push({ title: title.replace(/ side thread$/u, ""), body: "Thinking…", accent: "muted" });
|
|
257
|
+
if (error) displaySections.push({ title: "Reply failed", body: error, accent: "error" });
|
|
258
|
+
const { contentWidth } = dimensions(width, options);
|
|
259
|
+
const footerRows = inputBoxRows(draft, busy, contentWidth, theme);
|
|
260
|
+
const hint = busy ? "esc close · ↑/↓ scroll" : "↵ send · esc close · ↑/↓ scroll · ctrl-u clear";
|
|
261
|
+
const rendered = renderSpeechBubble(title, displaySections, hint, width, theme, {
|
|
262
|
+
...options,
|
|
263
|
+
scroll,
|
|
264
|
+
footerRows,
|
|
265
|
+
});
|
|
266
|
+
maxScroll = rendered.maxScroll;
|
|
267
|
+
if (scrollToBottom) {
|
|
268
|
+
scroll = maxScroll;
|
|
269
|
+
scrollToBottom = false;
|
|
270
|
+
} else {
|
|
271
|
+
clamp();
|
|
272
|
+
}
|
|
273
|
+
return renderSpeechBubble(title, displaySections, hint, width, theme, { ...options, scroll, footerRows }).lines;
|
|
274
|
+
},
|
|
275
|
+
invalidate: () => {},
|
|
276
|
+
handleInput: (data: string) => {
|
|
277
|
+
if (matchesKey(data, "escape")) {
|
|
278
|
+
done(undefined);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return")) {
|
|
282
|
+
void submit();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (matchesKey(data, Key.ctrl("u"))) {
|
|
286
|
+
draft = "";
|
|
287
|
+
refresh();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (matchesKey(data, "backspace") || matchesKey(data, Key.backspace)) {
|
|
291
|
+
draft = draft.slice(0, -1);
|
|
292
|
+
refresh();
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (matchesKey(data, "up")) scroll--;
|
|
296
|
+
else if (matchesKey(data, "down")) scroll++;
|
|
297
|
+
else if (matchesKey(data, "pageUp")) scroll -= pageSize();
|
|
298
|
+
else if (matchesKey(data, "pageDown")) scroll += pageSize();
|
|
299
|
+
else {
|
|
300
|
+
const text = printableInput(data);
|
|
301
|
+
if (!text || busy) return;
|
|
302
|
+
draft = `${draft}${text}`.slice(0, 500);
|
|
303
|
+
}
|
|
304
|
+
clamp();
|
|
305
|
+
refresh();
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|