pi-qq 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/LICENSE +21 -0
- package/NOTICE +1 -0
- package/README.md +49 -0
- package/index.ts +20 -0
- package/package.json +41 -0
- package/prompts/qq-system.txt +11 -0
- package/qq-ui.ts +181 -0
- package/qq.ts +252 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pi-qq 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/NOTICE
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
This package includes MIT-licensed portions copyright (c) 2026 juicesharp.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# pi-qq
|
|
2
|
+
|
|
3
|
+
A [pi](https://pi.dev) package that provides a self-contained `/qq` command for quick questions about the **main pi session**, plus an **alt+q** / **Option+Q** shortcut that toggles the `/qq ` prefix in the editor.
|
|
4
|
+
|
|
5
|
+
Do not install this alongside another package that registers `/qq`, because command names must be unique.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
- Adds `/qq <question>`.
|
|
10
|
+
- Sends the active model a read-only clone of the main session as context.
|
|
11
|
+
- Shows the answer in a bottom overlay, without adding anything to the main transcript.
|
|
12
|
+
- Uses a system prompt that assumes ambiguous questions are about the main session unless explicitly stated otherwise.
|
|
13
|
+
- Gives the quick-question side call no tools.
|
|
14
|
+
- Does **not** keep quick-question history. Each `/qq` call is independent except for the main-session context.
|
|
15
|
+
- Adds **alt+q** / **Option+Q** to toggle `/qq ` at the front of the editor.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
Install this package:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pi install npm:pi-qq
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
After installing, run `/reload` in pi or restart the session.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
/qq why are we changing this file?
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or press **alt+q** / **Option+Q**, type your quick question, and hit enter.
|
|
34
|
+
|
|
35
|
+
Pressing **alt+q** / **Option+Q** toggles the prefix:
|
|
36
|
+
|
|
37
|
+
- If the editor does not start with `/qq `, the prefix is prepended.
|
|
38
|
+
- If the editor already starts with `/qq `, the prefix is removed.
|
|
39
|
+
|
|
40
|
+
## Overlay keys
|
|
41
|
+
|
|
42
|
+
| Key | Action |
|
|
43
|
+
| --- | --- |
|
|
44
|
+
| `↑` / `↓` | Scroll the panel when content overflows |
|
|
45
|
+
| `Esc` | Close the panel; cancel the request if it is still running |
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-qq — Pi extension entry point.
|
|
3
|
+
*
|
|
4
|
+
* Registers:
|
|
5
|
+
* - /qq command for one-off quick questions against the active model
|
|
6
|
+
* - alt+q shortcut that toggles a /qq prefix in the editor
|
|
7
|
+
* - lightweight conversation snapshot hooks used as read-only context
|
|
8
|
+
*
|
|
9
|
+
* Includes the command, shortcut, and snapshot hooks for quick questions.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { registerQqCommand, registerQqShortcut, registerInvalidationHooks, registerMessageEndSnapshot } from "./qq.js";
|
|
14
|
+
|
|
15
|
+
export default function (pi: ExtensionAPI): void {
|
|
16
|
+
registerQqCommand(pi);
|
|
17
|
+
registerQqShortcut(pi);
|
|
18
|
+
registerMessageEndSnapshot(pi);
|
|
19
|
+
registerInvalidationHooks(pi);
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-qq",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension. Adds /qq for one-off quick questions about the main session, plus an alt+q shortcut to toggle the /qq prefix.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"quick-question",
|
|
9
|
+
"question",
|
|
10
|
+
"shortcut",
|
|
11
|
+
"keybinding",
|
|
12
|
+
"overlay"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --strict --skipLibCheck index.ts qq.ts qq-ui.ts"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"index.ts",
|
|
21
|
+
"qq.ts",
|
|
22
|
+
"qq-ui.ts",
|
|
23
|
+
"prompts/",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-ai": "*",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"@earendil-works/pi-tui": "*"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"typescript": "^6.0.3"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
You answer quick questions about the user's main pi session.
|
|
2
|
+
|
|
3
|
+
Default ambiguous references to the main session. "This", "that", "it", "we", "the plan", "the code", "the issue", and "what you were doing" refer to the primary conversation unless the user clearly says otherwise.
|
|
4
|
+
|
|
5
|
+
Treat the primary conversation as background only. Do not continue prior work, resume tool calls, or start a task. Answer only the quick question.
|
|
6
|
+
|
|
7
|
+
Optimize for speed and brevity. Answer in one short sentence by default. Use up to 3 terse bullets only when necessary. No preamble. No restating the question. No summary. If uncertain or missing context, say so in one short sentence.
|
|
8
|
+
|
|
9
|
+
Cite files/functions/lines only when necessary to ground a claim; otherwise skip citations.
|
|
10
|
+
|
|
11
|
+
You have no tools. Do not call tools. Plain text only.
|
package/qq-ui.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic-height bottom-slot overlay for /qq.
|
|
3
|
+
*
|
|
4
|
+
* Layout:
|
|
5
|
+
* banner (question summary)
|
|
6
|
+
* blank
|
|
7
|
+
* answer — body wrapped at width-2
|
|
8
|
+
* blank
|
|
9
|
+
* footer — key hints
|
|
10
|
+
*
|
|
11
|
+
* Keys:
|
|
12
|
+
* Esc → abort in-flight call + dismiss
|
|
13
|
+
* ↑/↓ → scroll when content exceeds terminal
|
|
14
|
+
*
|
|
15
|
+
* Does not keep quick-question history.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import type { OverlayOptions } from "@earendil-works/pi-tui";
|
|
20
|
+
import {
|
|
21
|
+
type Component,
|
|
22
|
+
Key,
|
|
23
|
+
matchesKey,
|
|
24
|
+
type TUI,
|
|
25
|
+
truncateToWidth,
|
|
26
|
+
visibleWidth,
|
|
27
|
+
wrapTextWithAnsi,
|
|
28
|
+
} from "@earendil-works/pi-tui";
|
|
29
|
+
|
|
30
|
+
const QQ_OVERLAY_OPTIONS: OverlayOptions = {
|
|
31
|
+
anchor: "bottom-center",
|
|
32
|
+
width: "100%",
|
|
33
|
+
maxHeight: "85%",
|
|
34
|
+
margin: { left: 0, right: 0, bottom: 0 },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const QQ_MAX_HEIGHT_RATIO = 0.85;
|
|
38
|
+
const SIDE_PAD = " ";
|
|
39
|
+
const ANSWER_PAD = " ";
|
|
40
|
+
const QQ_LITERAL = "/qq";
|
|
41
|
+
const PENDING_GLYPH = "…";
|
|
42
|
+
const FOOTER_SCROLL = "↑/↓ to scroll";
|
|
43
|
+
const FOOTER_DISMISS = "Esc to dismiss";
|
|
44
|
+
const FOOTER_SEP = " · ";
|
|
45
|
+
|
|
46
|
+
type Mode = "pending" | "answer" | "error";
|
|
47
|
+
|
|
48
|
+
export interface ShowQqOverlayParams {
|
|
49
|
+
ctx: ExtensionCommandContext;
|
|
50
|
+
question: string;
|
|
51
|
+
controller: AbortController;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ShowQqOverlayResult {
|
|
55
|
+
overlayPromise: Promise<void>;
|
|
56
|
+
controllerReady: Promise<QqOverlayController>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class QqOverlayController implements Component {
|
|
60
|
+
private mode: Mode = "pending";
|
|
61
|
+
private answer = "";
|
|
62
|
+
private error = "";
|
|
63
|
+
private scrollOffset = 0;
|
|
64
|
+
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly question: string,
|
|
67
|
+
private readonly theme: Theme,
|
|
68
|
+
private readonly tui: TUI,
|
|
69
|
+
private readonly done: (result?: undefined) => void,
|
|
70
|
+
private readonly controller: AbortController,
|
|
71
|
+
) {}
|
|
72
|
+
|
|
73
|
+
setAnswer(text: string): void {
|
|
74
|
+
this.mode = "answer";
|
|
75
|
+
this.answer = text;
|
|
76
|
+
this.tui.requestRender();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setError(message: string): void {
|
|
80
|
+
this.mode = "error";
|
|
81
|
+
this.error = message;
|
|
82
|
+
this.tui.requestRender();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handleInput(data: string): void {
|
|
86
|
+
if (matchesKey(data, Key.escape)) {
|
|
87
|
+
this.controller.abort();
|
|
88
|
+
this.done();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (matchesKey(data, Key.up)) {
|
|
92
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
93
|
+
this.tui.requestRender();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (matchesKey(data, Key.down)) {
|
|
97
|
+
this.scrollOffset = this.scrollOffset + 1;
|
|
98
|
+
this.tui.requestRender();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render(width: number): string[] {
|
|
104
|
+
const banner = this.renderBanner(width);
|
|
105
|
+
const answerLines = this.renderAnswer(width);
|
|
106
|
+
const footerAvail = Math.max(1, width - SIDE_PAD.length);
|
|
107
|
+
const footerParts: string[] = [];
|
|
108
|
+
if (this.mode !== "pending") footerParts.push(FOOTER_SCROLL);
|
|
109
|
+
footerParts.push(FOOTER_DISMISS);
|
|
110
|
+
const footer =
|
|
111
|
+
SIDE_PAD + truncateToWidth(this.theme.fg("dim", footerParts.join(FOOTER_SEP)), footerAvail, "…", false);
|
|
112
|
+
|
|
113
|
+
const natural: string[] = [banner, "", ...answerLines, "", footer];
|
|
114
|
+
|
|
115
|
+
const termRows = (this.tui.terminal as { rows?: number }).rows ?? 24;
|
|
116
|
+
const maxRows = Math.max(4, Math.floor(termRows * QQ_MAX_HEIGHT_RATIO));
|
|
117
|
+
if (natural.length <= maxRows) {
|
|
118
|
+
return natural;
|
|
119
|
+
}
|
|
120
|
+
const excess = natural.length - maxRows;
|
|
121
|
+
if (this.scrollOffset > excess) this.scrollOffset = excess;
|
|
122
|
+
const start = excess - this.scrollOffset;
|
|
123
|
+
return natural.slice(start, start + maxRows);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
invalidate(): void {
|
|
127
|
+
// Render recomputes from state each cycle.
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private renderBanner(width: number): string {
|
|
131
|
+
const prefix = `${SIDE_PAD}${QQ_LITERAL} `;
|
|
132
|
+
const prefixWidth = visibleWidth(prefix);
|
|
133
|
+
const questionWidth = Math.max(0, width - prefixWidth);
|
|
134
|
+
const truncatedQuestion = truncateToWidth(this.question, questionWidth, "…", false);
|
|
135
|
+
const raw = prefix + truncatedQuestion;
|
|
136
|
+
const padded = raw + " ".repeat(Math.max(0, width - visibleWidth(raw)));
|
|
137
|
+
return this.theme.bg("customMessageBg", this.theme.fg("customMessageText", padded));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
private renderAnswer(width: number): string[] {
|
|
142
|
+
const bodyWidth = Math.max(1, width - ANSWER_PAD.length);
|
|
143
|
+
const indent = (lines: string[]) => lines.map((line) => ANSWER_PAD + line);
|
|
144
|
+
|
|
145
|
+
if (this.mode === "pending") {
|
|
146
|
+
return indent([this.theme.fg("warning", PENDING_GLYPH)]);
|
|
147
|
+
}
|
|
148
|
+
if (this.mode === "error") {
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (const line of this.error.split("\n")) {
|
|
151
|
+
const source = line.length === 0 ? " " : line;
|
|
152
|
+
out.push(...wrapTextWithAnsi(this.theme.fg("error", source), bodyWidth));
|
|
153
|
+
}
|
|
154
|
+
return indent(out);
|
|
155
|
+
}
|
|
156
|
+
const out: string[] = [];
|
|
157
|
+
for (const line of this.answer.split("\n")) {
|
|
158
|
+
const source = line.length === 0 ? " " : line;
|
|
159
|
+
out.push(...wrapTextWithAnsi(source, bodyWidth));
|
|
160
|
+
}
|
|
161
|
+
return indent(out);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function showQqOverlay(params: ShowQqOverlayParams): ShowQqOverlayResult {
|
|
166
|
+
let resolveReady!: (controller: QqOverlayController) => void;
|
|
167
|
+
const controllerReady = new Promise<QqOverlayController>((resolve) => {
|
|
168
|
+
resolveReady = resolve;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const overlayPromise = params.ctx.ui.custom<void>(
|
|
172
|
+
(tui, theme, _keybindings, done) => {
|
|
173
|
+
const controller = new QqOverlayController(params.question, theme, tui, done, params.controller);
|
|
174
|
+
resolveReady(controller);
|
|
175
|
+
return controller;
|
|
176
|
+
},
|
|
177
|
+
{ overlay: true, overlayOptions: QQ_OVERLAY_OPTIONS },
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return { overlayPromise, controllerReady };
|
|
181
|
+
}
|
package/qq.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /qq quick-question slash command.
|
|
3
|
+
*
|
|
4
|
+
* Asks the same primary model a one-off quick question using the cloned primary
|
|
5
|
+
* conversation as read-only context. The answer is rendered ephemerally in a
|
|
6
|
+
* bottom-slot overlay and never enters the main session transcript.
|
|
7
|
+
*
|
|
8
|
+
* Does not keep quick-question history.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import {
|
|
14
|
+
type AssistantMessage,
|
|
15
|
+
completeSimple,
|
|
16
|
+
type Message,
|
|
17
|
+
type StopReason,
|
|
18
|
+
type UserMessage,
|
|
19
|
+
} from "@earendil-works/pi-ai";
|
|
20
|
+
import {
|
|
21
|
+
convertToLlm,
|
|
22
|
+
type ExtensionAPI,
|
|
23
|
+
type ExtensionCommandContext,
|
|
24
|
+
type ExtensionContext,
|
|
25
|
+
type SessionEntry,
|
|
26
|
+
} from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { showQqOverlay } from "./qq-ui.js";
|
|
28
|
+
|
|
29
|
+
export const QQ_COMMAND_NAME = "qq";
|
|
30
|
+
export const QQ_PREFIX = `/${QQ_COMMAND_NAME} `;
|
|
31
|
+
export const QQ_STATE_KEY = Symbol.for("pi-qq:qq");
|
|
32
|
+
|
|
33
|
+
const MSG_REQUIRES_INTERACTIVE = "/qq requires interactive mode";
|
|
34
|
+
const MSG_USAGE = "Usage: /qq <question>";
|
|
35
|
+
const MSG_NO_MODEL = "/qq requires an active model";
|
|
36
|
+
const ERR_EMPTY_RESPONSE = "/qq returned no text content.";
|
|
37
|
+
|
|
38
|
+
const errMisconfigured = (label: string, err: string) => `/qq model (${label}) is misconfigured: ${err}`;
|
|
39
|
+
const errNoApiKey = (label: string) => `/qq model (${label}) has no API key available.`;
|
|
40
|
+
const errCallFailed = (err: string | undefined) => `/qq call failed: ${err ?? "unknown error"}`;
|
|
41
|
+
const errCallThrew = (msg: string) => `/qq call threw: ${msg}`;
|
|
42
|
+
|
|
43
|
+
interface QqState {
|
|
44
|
+
snapshots: Map<string, { messages: Message[] }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const QQ_SYSTEM_PROMPT = readFileSync(
|
|
48
|
+
fileURLToPath(new URL("./prompts/qq-system.txt", import.meta.url)),
|
|
49
|
+
"utf-8",
|
|
50
|
+
).trimEnd();
|
|
51
|
+
|
|
52
|
+
function getState(): QqState {
|
|
53
|
+
const globalState = globalThis as unknown as { [k: symbol]: QqState | undefined };
|
|
54
|
+
let state = globalState[QQ_STATE_KEY];
|
|
55
|
+
if (!state) {
|
|
56
|
+
state = { snapshots: new Map() };
|
|
57
|
+
globalState[QQ_STATE_KEY] = state;
|
|
58
|
+
}
|
|
59
|
+
return state;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function getSessionFile(ctx: ExtensionContext): string {
|
|
63
|
+
return ctx.sessionManager.getSessionFile() ?? `memory:${ctx.sessionManager.getSessionId()}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getSnapshot(ctx: ExtensionContext): { messages: Message[] } | undefined {
|
|
67
|
+
return getState().snapshots.get(getSessionFile(ctx));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setSnapshot(ctx: ExtensionContext, snapshot: { messages: Message[] }): void {
|
|
71
|
+
getState().snapshots.set(getSessionFile(ctx), snapshot);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function invalidateSnapshot(ctx: ExtensionContext): void {
|
|
75
|
+
getState().snapshots.delete(getSessionFile(ctx));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assistantMessageText(msg: AssistantMessage): string {
|
|
79
|
+
return msg.content
|
|
80
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
81
|
+
.map((c) => c.text)
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface QqExecResult {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
answer?: string;
|
|
88
|
+
userMessage?: UserMessage;
|
|
89
|
+
assistantMessage?: AssistantMessage;
|
|
90
|
+
error?: string;
|
|
91
|
+
stopReason?: StopReason;
|
|
92
|
+
aborted?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readBranchMessages(ctx: ExtensionContext): Message[] {
|
|
96
|
+
const cached = getSnapshot(ctx);
|
|
97
|
+
if (cached) return cached.messages;
|
|
98
|
+
|
|
99
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
100
|
+
const agentMessages = branch
|
|
101
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
102
|
+
.map((entry) => entry.message);
|
|
103
|
+
return convertToLlm(agentMessages);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildQqMessages(ctx: ExtensionContext, userMessage: UserMessage): Message[] {
|
|
107
|
+
return [...readBranchMessages(ctx), userMessage];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function executeQq(
|
|
111
|
+
question: string,
|
|
112
|
+
ctx: ExtensionContext,
|
|
113
|
+
controller: AbortController,
|
|
114
|
+
): Promise<QqExecResult> {
|
|
115
|
+
const model = ctx.model;
|
|
116
|
+
if (!model) {
|
|
117
|
+
return { ok: false, error: MSG_NO_MODEL };
|
|
118
|
+
}
|
|
119
|
+
const modelLabel = `${model.provider}:${model.id}`;
|
|
120
|
+
|
|
121
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
122
|
+
if (!auth.ok) {
|
|
123
|
+
return { ok: false, error: errMisconfigured(modelLabel, auth.error) };
|
|
124
|
+
}
|
|
125
|
+
if (!auth.apiKey) {
|
|
126
|
+
return { ok: false, error: errNoApiKey(modelLabel) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const userMessage: UserMessage = {
|
|
130
|
+
role: "user",
|
|
131
|
+
content: [{ type: "text", text: question }],
|
|
132
|
+
timestamp: Date.now(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const response = await completeSimple(
|
|
137
|
+
model,
|
|
138
|
+
{ systemPrompt: QQ_SYSTEM_PROMPT, messages: buildQqMessages(ctx, userMessage), tools: [] },
|
|
139
|
+
{
|
|
140
|
+
apiKey: auth.apiKey,
|
|
141
|
+
headers: auth.headers,
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
if (response.stopReason === "aborted") {
|
|
147
|
+
return { ok: false, aborted: true, stopReason: response.stopReason };
|
|
148
|
+
}
|
|
149
|
+
if (response.stopReason === "error") {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
error: errCallFailed(response.errorMessage),
|
|
153
|
+
stopReason: response.stopReason,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const answerText = assistantMessageText(response).trim();
|
|
158
|
+
if (!answerText) {
|
|
159
|
+
return { ok: false, error: ERR_EMPTY_RESPONSE, stopReason: response.stopReason };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
answer: answerText,
|
|
165
|
+
userMessage,
|
|
166
|
+
assistantMessage: response,
|
|
167
|
+
stopReason: response.stopReason,
|
|
168
|
+
};
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
171
|
+
if (controller.signal.aborted) {
|
|
172
|
+
return { ok: false, aborted: true };
|
|
173
|
+
}
|
|
174
|
+
return { ok: false, error: errCallThrew(message) };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function registerMessageEndSnapshot(pi: ExtensionAPI): void {
|
|
179
|
+
pi.on("message_end", async (event, ctx) => {
|
|
180
|
+
const msg = event.message;
|
|
181
|
+
if (msg.role !== "assistant") return;
|
|
182
|
+
if ((msg as AssistantMessage).stopReason === "toolUse") return;
|
|
183
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
184
|
+
const agentMessages = branch
|
|
185
|
+
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
186
|
+
.map((entry) => entry.message);
|
|
187
|
+
setSnapshot(ctx, { messages: convertToLlm(agentMessages) });
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function registerInvalidationHooks(pi: ExtensionAPI): void {
|
|
192
|
+
pi.on("session_compact", async (_event, ctx) => invalidateSnapshot(ctx));
|
|
193
|
+
pi.on("session_tree", async (_event, ctx) => invalidateSnapshot(ctx));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function registerQqShortcut(pi: ExtensionAPI): void {
|
|
197
|
+
pi.registerShortcut("alt+q", {
|
|
198
|
+
description: "Toggle /qq quick-question prefix",
|
|
199
|
+
handler: async (ctx) => {
|
|
200
|
+
if (!ctx.hasUI) return;
|
|
201
|
+
const current = ctx.ui.getEditorText() ?? "";
|
|
202
|
+
if (current.startsWith(QQ_PREFIX)) {
|
|
203
|
+
ctx.ui.setEditorText(current.slice(QQ_PREFIX.length));
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
ctx.ui.setEditorText(QQ_PREFIX + current);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function registerQqCommand(pi: ExtensionAPI): void {
|
|
212
|
+
pi.registerCommand(QQ_COMMAND_NAME, {
|
|
213
|
+
description: "Ask a quick question without polluting the main conversation",
|
|
214
|
+
handler: (args: string, ctx: ExtensionCommandContext) => handleQqCommand(args, ctx),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
219
|
+
if (!ctx.hasUI) {
|
|
220
|
+
ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const question = args.trim();
|
|
224
|
+
if (!question) {
|
|
225
|
+
ctx.ui.notify(MSG_USAGE, "warning");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!ctx.model) {
|
|
229
|
+
ctx.ui.notify(MSG_NO_MODEL, "error");
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const controller = new AbortController();
|
|
234
|
+
const { overlayPromise, controllerReady } = showQqOverlay({
|
|
235
|
+
ctx,
|
|
236
|
+
question,
|
|
237
|
+
controller,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const overlayCtl = await controllerReady;
|
|
241
|
+
const result = await executeQq(question, ctx, controller);
|
|
242
|
+
|
|
243
|
+
if (result.ok && result.answer) {
|
|
244
|
+
overlayCtl.setAnswer(result.answer);
|
|
245
|
+
} else if (result.aborted) {
|
|
246
|
+
// User Esc'd — overlay already dismissed via done(); no further action.
|
|
247
|
+
} else if (result.error) {
|
|
248
|
+
overlayCtl.setError(result.error);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await overlayPromise;
|
|
252
|
+
}
|