@narumitw/pi-btw 0.1.4

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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/package.json +37 -0
  4. package/src/btw.ts +220 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
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,51 @@
1
+ # pi-btw
2
+
3
+ A public [pi](https://pi.dev) extension package that adds `/btw`, a side-question command for asking quick questions without interrupting the main conversation.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install npm:@narumitw/pi-btw
9
+ ```
10
+
11
+ Try without installing:
12
+
13
+ ```bash
14
+ pi -e npm:@narumitw/pi-btw
15
+ ```
16
+
17
+ Try this package locally from the repository root:
18
+
19
+ ```bash
20
+ pi -e ./extensions/pi-btw
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```text
26
+ /btw <your side question>
27
+ ```
28
+
29
+ The command answers the question in a temporary UI using the current session branch as context, but it does not append the side question or answer to the main conversation.
30
+
31
+ ## Package layout
32
+
33
+ ```txt
34
+ extensions/pi-btw/
35
+ ├── src/
36
+ │ └── btw.ts
37
+ ├── README.md
38
+ ├── LICENSE
39
+ ├── tsconfig.json
40
+ └── package.json
41
+ ```
42
+
43
+ The package exposes its extension through `package.json`:
44
+
45
+ ```json
46
+ {
47
+ "pi": {
48
+ "extensions": ["./src/btw.ts"]
49
+ }
50
+ }
51
+ ```
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@narumitw/pi-btw",
3
+ "version": "0.1.4",
4
+ "description": "Pi extension that adds a /btw side-question command.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "btw",
13
+ "side-question"
14
+ ],
15
+ "files": [
16
+ "src",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./src/btw.ts"
23
+ ]
24
+ },
25
+ "scripts": {
26
+ "check": "biome check . && npm run typecheck",
27
+ "format": "biome check --write .",
28
+ "typecheck": "tsc --noEmit"
29
+ },
30
+ "devDependencies": {
31
+ "@biomejs/biome": "2.4.14",
32
+ "@mariozechner/pi-ai": "0.73.0",
33
+ "@mariozechner/pi-coding-agent": "0.73.0",
34
+ "@mariozechner/pi-tui": "0.73.0",
35
+ "typescript": "6.0.3"
36
+ }
37
+ }
package/src/btw.ts ADDED
@@ -0,0 +1,220 @@
1
+ import { complete, type UserMessage } from "@mariozechner/pi-ai";
2
+ import {
3
+ BorderedLoader,
4
+ DynamicBorder,
5
+ getMarkdownTheme,
6
+ type ExtensionAPI,
7
+ type ExtensionCommandContext,
8
+ } from "@mariozechner/pi-coding-agent";
9
+ import { Container, Markdown, matchesKey, Text } from "@mariozechner/pi-tui";
10
+
11
+ const MAX_CONTEXT_CHARS = 40_000;
12
+ const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
13
+
14
+ Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
15
+
16
+ type MessageContentBlock = {
17
+ type?: string;
18
+ text?: string;
19
+ name?: string;
20
+ arguments?: unknown;
21
+ result?: unknown;
22
+ };
23
+
24
+ type SessionMessage = {
25
+ role?: string;
26
+ content?: unknown;
27
+ stopReason?: string;
28
+ };
29
+
30
+ type SessionEntry = {
31
+ type: string;
32
+ message?: SessionMessage;
33
+ };
34
+
35
+ export default function btw(pi: ExtensionAPI) {
36
+ pi.registerCommand("btw", {
37
+ description: "Ask a quick side question without adding it to the main conversation",
38
+ handler: async (args, ctx) => {
39
+ const question = args.trim();
40
+ if (!question) {
41
+ ctx.ui.notify("Usage: /btw <your side question>", "warning");
42
+ return;
43
+ }
44
+
45
+ if (!ctx.hasUI) {
46
+ ctx.ui.notify("/btw requires interactive mode", "error");
47
+ return;
48
+ }
49
+
50
+ if (!ctx.model) {
51
+ ctx.ui.notify("No model selected", "error");
52
+ return;
53
+ }
54
+
55
+ const answer = await askSideQuestion(question, ctx);
56
+ if (answer === undefined) {
57
+ ctx.ui.notify("Cancelled", "info");
58
+ return;
59
+ }
60
+
61
+ await showAnswer(question, answer, ctx);
62
+ },
63
+ });
64
+ }
65
+
66
+ async function askSideQuestion(
67
+ question: string,
68
+ ctx: ExtensionCommandContext,
69
+ ): Promise<string | undefined> {
70
+ return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
71
+ const loader = new BorderedLoader(tui, theme, `Answering /btw with ${ctx.model!.id}...`);
72
+ loader.onAbort = () => done(undefined);
73
+
74
+ const ask = async () => {
75
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
76
+ if (!auth.ok || !auth.apiKey) {
77
+ throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
78
+ }
79
+
80
+ const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
81
+ const userMessage: UserMessage = {
82
+ role: "user",
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: buildUserPrompt(question, conversationContext),
87
+ },
88
+ ],
89
+ timestamp: Date.now(),
90
+ };
91
+
92
+ const response = await complete(
93
+ ctx.model!,
94
+ { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
95
+ { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
96
+ );
97
+
98
+ if (response.stopReason === "aborted") {
99
+ return undefined;
100
+ }
101
+
102
+ const text = response.content
103
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
104
+ .map((content) => content.text)
105
+ .join("\n")
106
+ .trim();
107
+
108
+ return text || "No response received.";
109
+ };
110
+
111
+ ask()
112
+ .then(done)
113
+ .catch((error: unknown) => {
114
+ const message = error instanceof Error ? error.message : String(error);
115
+ done(`Error: ${message}`);
116
+ });
117
+
118
+ return loader;
119
+ });
120
+ }
121
+
122
+ async function showAnswer(question: string, answer: string, ctx: ExtensionCommandContext) {
123
+ await ctx.ui.custom((_tui, theme, _keybindings, done) => {
124
+ const container = new Container();
125
+ const border = new DynamicBorder((text: string) => theme.fg("warning", text));
126
+ const markdownTheme = getMarkdownTheme();
127
+
128
+ container.addChild(border);
129
+ container.addChild(new Text(theme.fg("warning", theme.bold(`/btw ${question}`)), 1, 0));
130
+ container.addChild(new Markdown(answer, 1, 1, markdownTheme));
131
+ container.addChild(new Text(theme.fg("dim", "Press Enter, Space, or Esc to close"), 1, 1));
132
+ container.addChild(border);
133
+
134
+ return {
135
+ render: (width: number) => container.render(width),
136
+ invalidate: () => container.invalidate(),
137
+ handleInput: (data: string) => {
138
+ if (matchesKey(data, "enter") || matchesKey(data, "space") || matchesKey(data, "escape")) {
139
+ done(undefined);
140
+ }
141
+ },
142
+ };
143
+ });
144
+ }
145
+
146
+ function buildUserPrompt(question: string, conversationContext: string) {
147
+ return [
148
+ "Answer this side question without modifying the main conversation.",
149
+ "",
150
+ "<side_question>",
151
+ question,
152
+ "</side_question>",
153
+ "",
154
+ "<conversation_context>",
155
+ conversationContext || "No prior conversation context was available.",
156
+ "</conversation_context>",
157
+ ].join("\n");
158
+ }
159
+
160
+ function buildConversationContext(entries: readonly SessionEntry[]) {
161
+ const sections: string[] = [];
162
+
163
+ for (const entry of entries) {
164
+ if (entry.type !== "message" || !entry.message?.role) continue;
165
+
166
+ const role = entry.message.role;
167
+ if (role !== "user" && role !== "assistant") continue;
168
+
169
+ const contentLines = extractContentLines(entry.message.content);
170
+ if (contentLines.length === 0) continue;
171
+
172
+ const label = role === "user" ? "User" : "Assistant";
173
+ const status = entry.message.stopReason && entry.message.stopReason !== "stop"
174
+ ? ` (${entry.message.stopReason})`
175
+ : "";
176
+ sections.push(`${label}${status}: ${contentLines.join("\n")}`);
177
+ }
178
+
179
+ return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
180
+ }
181
+
182
+ function extractContentLines(content: unknown): string[] {
183
+ if (typeof content === "string") {
184
+ return [content.trim()].filter(Boolean);
185
+ }
186
+
187
+ if (!Array.isArray(content)) {
188
+ return [];
189
+ }
190
+
191
+ const lines: string[] = [];
192
+ for (const part of content) {
193
+ if (!part || typeof part !== "object") continue;
194
+
195
+ const block = part as MessageContentBlock;
196
+ if (block.type === "text" && typeof block.text === "string") {
197
+ lines.push(block.text.trim());
198
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
199
+ lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
200
+ } else if (block.type === "toolResult" && typeof block.name === "string") {
201
+ lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
202
+ }
203
+ }
204
+
205
+ return lines.filter(Boolean);
206
+ }
207
+
208
+ function formatJson(value: unknown) {
209
+ if (value === undefined) return "";
210
+ try {
211
+ return JSON.stringify(value);
212
+ } catch {
213
+ return String(value);
214
+ }
215
+ }
216
+
217
+ function truncateFromStart(text: string, maxChars: number) {
218
+ if (text.length <= maxChars) return text;
219
+ return `[Earlier context omitted; showing the last ${maxChars} characters.]\n${text.slice(-maxChars)}`;
220
+ }