pi-qq 0.1.6 → 0.1.8
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/README.md +15 -9
- package/package.json +3 -3
- package/qq-ui.ts +12 -3
- package/qq.ts +84 -4
package/README.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Ask quick side questions about your current [pi](https://pi.dev) session without polluting the main transcript.
|
|
4
4
|
|
|
5
|
-
`pi-qq` adds `/qq <question>` plus an **alt+q** / **Option+Q** shortcut that toggles `/qq ` in the editor.
|
|
5
|
+
`pi-qq` adds `/qq <question>` plus an **alt+q** / **Option+Q** shortcut that toggles `/qq ` in the editor. Answers appear in a dismissible bottom overlay, can be reopened from in-memory `/qq-history`, and never enter the main conversation.
|
|
6
6
|
|
|
7
7
|
## Why try it?
|
|
8
8
|
|
|
9
9
|
- **A real side channel:** ask `/qq why are we changing this file?` while the main agent keeps working. The answer shows in a bottom overlay and does not enter the main transcript.
|
|
10
|
-
- **Context-aware, intentionally constrained:** `/qq` passes read-only main-session context, treats ambiguous references like “this”, “that”, “we”, and “the plan” as references to the active session, gives the side call no tools
|
|
11
|
-
- **Fast, low-friction UX:**
|
|
10
|
+
- **Context-aware, intentionally constrained:** `/qq` passes read-only main-session context, treats ambiguous references like “this”, “that”, “we”, and “the plan” as references to the active session, and gives the side call no tools. Previous `/qq` answers are available only through `/qq-history`; they are not fed back into future `/qq` calls.
|
|
11
|
+
- **Fast, low-friction UX:** press **alt+q** / **Option+Q** to toggle `/qq `, then use **Esc** to cancel/dismiss or **↑/↓** to scroll longer answers.
|
|
12
|
+
- **Smart context modes:** `/qq` uses recent context by default, automatically switches to broader bounded context for retrospective questions, and supports explicit `--recent` / `--full` modes.
|
|
12
13
|
|
|
13
14
|
## Demo
|
|
14
15
|
|
|
@@ -23,7 +24,7 @@ Another common flow:
|
|
|
23
24
|
1. Press **alt+q** / **Option+Q**.
|
|
24
25
|
2. Type `what's the risk with this plan?`.
|
|
25
26
|
3. Hit Enter.
|
|
26
|
-
4. Read the concise overlay answer; press **Esc** to dismiss.
|
|
27
|
+
4. Read the concise overlay answer; press **Esc** to dismiss. If you close it too soon, run `/qq-history` to reopen recent answers.
|
|
27
28
|
|
|
28
29
|
## Install
|
|
29
30
|
|
|
@@ -41,14 +42,18 @@ After installing, run `/reload` in pi or restart the session.
|
|
|
41
42
|
/qq <question>
|
|
42
43
|
/qq --recent <question>
|
|
43
44
|
/qq --full <question>
|
|
45
|
+
/qq-history
|
|
44
46
|
```
|
|
45
47
|
|
|
46
48
|
By default, `/qq` chooses a context mode automatically:
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
| Mode | When to use it | Context sent |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| Auto | Default for `/qq <question>` | Recent context for immediate questions, broader bounded context for retrospective questions |
|
|
53
|
+
| `--recent` | Fastest answers about the latest work | Latest messages only |
|
|
54
|
+
| `--full` | Recaps or questions about earlier decisions | Broader but still bounded session context, not unlimited history |
|
|
50
55
|
|
|
51
|
-
Use
|
|
56
|
+
Use `/qq-history` to reopen recent `/qq` answers from the current session. History is view-only and is not included as context for future `/qq` model calls.
|
|
52
57
|
|
|
53
58
|
### Shortcut
|
|
54
59
|
|
|
@@ -68,10 +73,11 @@ Press **alt+q** / **Option+Q** to toggle `/qq ` at the front of the editor:
|
|
|
68
73
|
|
|
69
74
|
- The main transcript is never polluted by `/qq` questions or answers.
|
|
70
75
|
- The side call receives read-only main-session context.
|
|
71
|
-
- Recent mode sends only the latest messages for speed; full mode sends broader bounded context.
|
|
76
|
+
- Recent mode sends only the latest messages for speed; full mode sends broader but still bounded context, not unlimited history.
|
|
72
77
|
- Large text parts are clipped, thinking blocks are removed, and images are omitted from the side-call context for speed.
|
|
73
78
|
- The side call has no tools.
|
|
74
|
-
- `/qq`
|
|
79
|
+
- Recent `/qq` answers are kept in memory only so `/qq-history` can reopen them after dismissal.
|
|
80
|
+
- `/qq-history` is view-only; it is not used as context for future `/qq` model calls.
|
|
75
81
|
- The system prompt biases answers toward concise, direct responses.
|
|
76
82
|
|
|
77
83
|
## License
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-qq",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "Ask transcript-safe, context-aware side questions in Pi with /qq or alt+q, then reopen recent answers with /qq-history.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi-extension",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"side-question",
|
|
10
10
|
"context-aware",
|
|
11
11
|
"transcript-safe",
|
|
12
|
-
"
|
|
12
|
+
"history",
|
|
13
13
|
"question",
|
|
14
14
|
"shortcut",
|
|
15
15
|
"keybinding",
|
package/qq-ui.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Esc → abort in-flight call + dismiss
|
|
13
13
|
* ↑/↓ → scroll when content exceeds terminal
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* Used for both live /qq answers and view-only /qq-history.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
@@ -49,6 +49,7 @@ export interface ShowQqOverlayParams {
|
|
|
49
49
|
ctx: ExtensionCommandContext;
|
|
50
50
|
question: string;
|
|
51
51
|
controller: AbortController;
|
|
52
|
+
commandLabel?: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export interface ShowQqOverlayResult {
|
|
@@ -68,6 +69,7 @@ export class QqOverlayController implements Component {
|
|
|
68
69
|
private readonly tui: TUI,
|
|
69
70
|
private readonly done: (result?: undefined) => void,
|
|
70
71
|
private readonly controller: AbortController,
|
|
72
|
+
private readonly commandLabel: string = QQ_LITERAL,
|
|
71
73
|
) {}
|
|
72
74
|
|
|
73
75
|
setAnswer(text: string): void {
|
|
@@ -128,7 +130,7 @@ export class QqOverlayController implements Component {
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
private renderBanner(width: number): string {
|
|
131
|
-
const prefix = `${SIDE_PAD}${
|
|
133
|
+
const prefix = `${SIDE_PAD}${this.commandLabel} `;
|
|
132
134
|
const prefixWidth = visibleWidth(prefix);
|
|
133
135
|
const questionWidth = Math.max(0, width - prefixWidth);
|
|
134
136
|
const truncatedQuestion = truncateToWidth(this.question, questionWidth, "…", false);
|
|
@@ -170,7 +172,14 @@ export function showQqOverlay(params: ShowQqOverlayParams): ShowQqOverlayResult
|
|
|
170
172
|
|
|
171
173
|
const overlayPromise = params.ctx.ui.custom<void>(
|
|
172
174
|
(tui, theme, _keybindings, done) => {
|
|
173
|
-
const controller = new QqOverlayController(
|
|
175
|
+
const controller = new QqOverlayController(
|
|
176
|
+
params.question,
|
|
177
|
+
theme,
|
|
178
|
+
tui,
|
|
179
|
+
done,
|
|
180
|
+
params.controller,
|
|
181
|
+
params.commandLabel,
|
|
182
|
+
);
|
|
174
183
|
resolveReady(controller);
|
|
175
184
|
return controller;
|
|
176
185
|
},
|
package/qq.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* conversation as read-only context. The answer is rendered ephemerally in a
|
|
6
6
|
* bottom-slot overlay and never enters the main session transcript.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Keeps in-memory, view-only answer history for /qq-history.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { readFileSync } from "node:fs";
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { showQqOverlay } from "./qq-ui.js";
|
|
28
28
|
|
|
29
29
|
export const QQ_COMMAND_NAME = "qq";
|
|
30
|
+
export const QQ_HISTORY_COMMAND_NAME = "qq-history";
|
|
30
31
|
export const QQ_PREFIX = `/${QQ_COMMAND_NAME} `;
|
|
31
32
|
export const QQ_STATE_KEY = Symbol.for("pi-qq:qq");
|
|
32
33
|
|
|
@@ -34,6 +35,8 @@ const MSG_REQUIRES_INTERACTIVE = "/qq requires interactive mode";
|
|
|
34
35
|
const MSG_USAGE = "Usage: /qq [--recent|--full] <question>";
|
|
35
36
|
const MSG_NO_MODEL = "/qq requires an active model";
|
|
36
37
|
const ERR_EMPTY_RESPONSE = "/qq returned no text content.";
|
|
38
|
+
const MSG_NO_HISTORY = "No /qq history for this session yet";
|
|
39
|
+
const QQ_HISTORY_LIMIT = 20;
|
|
37
40
|
const RECENT_CONTEXT_MESSAGE_LIMIT = 12;
|
|
38
41
|
const FULL_CONTEXT_HEAD_MESSAGE_LIMIT = 4;
|
|
39
42
|
const FULL_CONTEXT_TAIL_MESSAGE_LIMIT = 80;
|
|
@@ -51,8 +54,15 @@ interface ParsedQqArgs {
|
|
|
51
54
|
mode: QqContextMode;
|
|
52
55
|
}
|
|
53
56
|
|
|
57
|
+
interface QqHistoryEntry {
|
|
58
|
+
question: string;
|
|
59
|
+
answer: string;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
interface QqState {
|
|
55
64
|
snapshots: Map<string, { messages: Message[] }>;
|
|
65
|
+
histories: Map<string, QqHistoryEntry[]>;
|
|
56
66
|
}
|
|
57
67
|
|
|
58
68
|
export const QQ_SYSTEM_PROMPT = readFileSync(
|
|
@@ -61,13 +71,15 @@ export const QQ_SYSTEM_PROMPT = readFileSync(
|
|
|
61
71
|
).trimEnd();
|
|
62
72
|
|
|
63
73
|
function getState(): QqState {
|
|
64
|
-
const globalState = globalThis as unknown as { [k: symbol]: QqState | undefined };
|
|
74
|
+
const globalState = globalThis as unknown as { [k: symbol]: Partial<QqState> | undefined };
|
|
65
75
|
let state = globalState[QQ_STATE_KEY];
|
|
66
76
|
if (!state) {
|
|
67
|
-
state = {
|
|
77
|
+
state = {};
|
|
68
78
|
globalState[QQ_STATE_KEY] = state;
|
|
69
79
|
}
|
|
70
|
-
|
|
80
|
+
state.snapshots ??= new Map();
|
|
81
|
+
state.histories ??= new Map();
|
|
82
|
+
return state as QqState;
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
function getSessionFile(ctx: ExtensionContext): string {
|
|
@@ -82,10 +94,45 @@ function setSnapshot(ctx: ExtensionContext, snapshot: { messages: Message[] }):
|
|
|
82
94
|
getState().snapshots.set(getSessionFile(ctx), snapshot);
|
|
83
95
|
}
|
|
84
96
|
|
|
97
|
+
function getSessionHistory(ctx: ExtensionContext): QqHistoryEntry[] {
|
|
98
|
+
const key = getSessionFile(ctx);
|
|
99
|
+
const state = getState();
|
|
100
|
+
let history = state.histories.get(key);
|
|
101
|
+
if (!history) {
|
|
102
|
+
history = [];
|
|
103
|
+
state.histories.set(key, history);
|
|
104
|
+
}
|
|
105
|
+
return history;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pushSessionHistory(ctx: ExtensionContext, entry: QqHistoryEntry): void {
|
|
109
|
+
const history = getSessionHistory(ctx);
|
|
110
|
+
history.push(entry);
|
|
111
|
+
if (history.length > QQ_HISTORY_LIMIT) {
|
|
112
|
+
history.splice(0, history.length - QQ_HISTORY_LIMIT);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
85
116
|
export function invalidateSnapshot(ctx: ExtensionContext): void {
|
|
86
117
|
getState().snapshots.delete(getSessionFile(ctx));
|
|
87
118
|
}
|
|
88
119
|
|
|
120
|
+
function formatHistoryTimestamp(timestamp: number): string {
|
|
121
|
+
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatQqHistory(entries: QqHistoryEntry[]): string {
|
|
125
|
+
return entries
|
|
126
|
+
.slice()
|
|
127
|
+
.reverse()
|
|
128
|
+
.map((entry, index) => {
|
|
129
|
+
const question = entry.question.replace(/\s+/g, " ").trim();
|
|
130
|
+
const answer = entry.answer.trim();
|
|
131
|
+
return `${index + 1}. ${formatHistoryTimestamp(entry.timestamp)} — /qq ${question}\n${answer}`;
|
|
132
|
+
})
|
|
133
|
+
.join("\n\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
89
136
|
export function assistantMessageText(msg: AssistantMessage): string {
|
|
90
137
|
return msg.content
|
|
91
138
|
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
@@ -344,6 +391,10 @@ export function registerQqCommand(pi: ExtensionAPI): void {
|
|
|
344
391
|
description: "Ask a quick question without polluting the main conversation",
|
|
345
392
|
handler: (args: string, ctx: ExtensionCommandContext) => handleQqCommand(args, ctx),
|
|
346
393
|
});
|
|
394
|
+
pi.registerCommand(QQ_HISTORY_COMMAND_NAME, {
|
|
395
|
+
description: "Show recent /qq answers for this session",
|
|
396
|
+
handler: (_args: string, ctx: ExtensionCommandContext) => handleQqHistoryCommand(ctx),
|
|
397
|
+
});
|
|
347
398
|
}
|
|
348
399
|
|
|
349
400
|
async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
@@ -372,6 +423,11 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
|
|
|
372
423
|
const result = await executeQq(parsedArgs.question, parsedArgs.mode, ctx, controller);
|
|
373
424
|
|
|
374
425
|
if (result.ok && result.answer) {
|
|
426
|
+
pushSessionHistory(ctx, {
|
|
427
|
+
question: parsedArgs.question,
|
|
428
|
+
answer: result.answer,
|
|
429
|
+
timestamp: Date.now(),
|
|
430
|
+
});
|
|
375
431
|
overlayCtl.setAnswer(result.answer);
|
|
376
432
|
} else if (result.aborted) {
|
|
377
433
|
// User Esc'd — overlay already dismissed via done(); no further action.
|
|
@@ -381,3 +437,27 @@ async function handleQqCommand(args: string, ctx: ExtensionCommandContext): Prom
|
|
|
381
437
|
|
|
382
438
|
await overlayPromise;
|
|
383
439
|
}
|
|
440
|
+
|
|
441
|
+
async function handleQqHistoryCommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
442
|
+
if (!ctx.hasUI) {
|
|
443
|
+
ctx.ui.notify(MSG_REQUIRES_INTERACTIVE, "error");
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const history = getSessionHistory(ctx);
|
|
447
|
+
if (history.length === 0) {
|
|
448
|
+
ctx.ui.notify(MSG_NO_HISTORY, "info");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const controller = new AbortController();
|
|
453
|
+
const { overlayPromise, controllerReady } = showQqOverlay({
|
|
454
|
+
ctx,
|
|
455
|
+
question: `${history.length} recent answer${history.length === 1 ? "" : "s"}`,
|
|
456
|
+
controller,
|
|
457
|
+
commandLabel: "/qq-history",
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const overlayCtl = await controllerReady;
|
|
461
|
+
overlayCtl.setAnswer(formatQqHistory(history));
|
|
462
|
+
await overlayPromise;
|
|
463
|
+
}
|