pi-ui-extend 0.1.9 → 0.1.11
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 +23 -2
- package/dist/app/app.d.ts +4 -0
- package/dist/app/app.js +74 -7
- package/dist/app/cli/install.d.ts +2 -0
- package/dist/app/cli/install.js +16 -1
- package/dist/app/commands/command-controller.js +4 -0
- package/dist/app/commands/command-host.d.ts +4 -0
- package/dist/app/commands/command-model-actions.d.ts +5 -0
- package/dist/app/commands/command-model-actions.js +104 -0
- package/dist/app/commands/command-navigation-actions.d.ts +6 -1
- package/dist/app/commands/command-navigation-actions.js +37 -14
- package/dist/app/commands/command-registry.d.ts +4 -0
- package/dist/app/commands/command-registry.js +32 -0
- package/dist/app/commands/command-session-actions.d.ts +1 -0
- package/dist/app/commands/command-session-actions.js +15 -5
- package/dist/app/commands/shell-controller.d.ts +1 -0
- package/dist/app/commands/shell-controller.js +1 -1
- package/dist/app/constants.d.ts +1 -1
- package/dist/app/constants.js +1 -1
- package/dist/app/icons.js +1 -1
- package/dist/app/input/autocomplete-controller.d.ts +52 -0
- package/dist/app/input/autocomplete-controller.js +352 -0
- package/dist/app/input/input-action-controller.d.ts +1 -0
- package/dist/app/input/input-action-controller.js +21 -0
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +2 -0
- package/dist/app/input/input-paste-handler.d.ts +1 -0
- package/dist/app/input/input-paste-handler.js +22 -18
- package/dist/app/input/voice-controller.d.ts +2 -0
- package/dist/app/input/voice-controller.js +27 -15
- package/dist/app/model/model-usage-status.d.ts +9 -0
- package/dist/app/model/model-usage-status.js +124 -34
- package/dist/app/popup/popup-action-controller.js +1 -1
- package/dist/app/process.d.ts +17 -0
- package/dist/app/process.js +68 -0
- package/dist/app/rendering/conversation-entry-renderer.js +17 -6
- package/dist/app/rendering/conversation-tool-renderer.js +3 -2
- package/dist/app/rendering/editor-layout-renderer.d.ts +1 -0
- package/dist/app/rendering/editor-layout-renderer.js +11 -1
- package/dist/app/rendering/message-content.js +65 -7
- package/dist/app/rendering/render-controller.js +6 -1
- package/dist/app/rendering/render-text.d.ts +3 -0
- package/dist/app/rendering/render-text.js +51 -3
- package/dist/app/rendering/status-line-renderer.d.ts +5 -1
- package/dist/app/rendering/status-line-renderer.js +69 -25
- package/dist/app/rendering/tool-block-renderer.js +13 -31
- package/dist/app/runtime.d.ts +6 -1
- package/dist/app/runtime.js +35 -2
- package/dist/app/screen/clipboard.d.ts +2 -2
- package/dist/app/screen/clipboard.js +13 -18
- package/dist/app/screen/mouse-controller.d.ts +5 -2
- package/dist/app/screen/mouse-controller.js +16 -1
- package/dist/app/screen/screen-styler.d.ts +4 -1
- package/dist/app/screen/screen-styler.js +3 -2
- package/dist/app/screen/status-controller.d.ts +3 -0
- package/dist/app/screen/status-controller.js +23 -8
- package/dist/app/session/queued-message-controller.d.ts +7 -1
- package/dist/app/session/queued-message-controller.js +32 -21
- package/dist/app/session/resume-session-loader.d.ts +15 -0
- package/dist/app/session/resume-session-loader.js +204 -0
- package/dist/app/session/session-event-controller.d.ts +5 -1
- package/dist/app/session/session-event-controller.js +72 -5
- package/dist/app/session/session-history.js +4 -3
- package/dist/app/session/session-lifecycle-controller.d.ts +5 -0
- package/dist/app/session/session-lifecycle-controller.js +9 -1
- package/dist/app/session/tabs-controller.d.ts +10 -1
- package/dist/app/session/tabs-controller.js +101 -5
- package/dist/app/terminal/nerd-font-controller.js +16 -17
- package/dist/app/terminal/terminal-controller.d.ts +1 -0
- package/dist/app/terminal/terminal-controller.js +1 -0
- package/dist/app/types.d.ts +14 -0
- package/dist/app/workspace/workspace-actions-controller.d.ts +1 -1
- package/dist/app/workspace/workspace-actions-controller.js +3 -3
- package/dist/app/workspace/workspace-undo.d.ts +1 -1
- package/dist/app/workspace/workspace-undo.js +22 -20
- package/dist/config.d.ts +27 -0
- package/dist/config.js +174 -1
- package/dist/default-pix-config.js +38 -353
- package/dist/input-editor.d.ts +7 -1
- package/dist/input-editor.js +47 -6
- package/dist/markdown-format.d.ts +1 -0
- package/dist/markdown-format.js +26 -1
- package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -0
- package/external/pi-tools-suite/src/dcp/prompts.ts +1 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +45 -195
- package/external/pi-tools-suite/src/lib/lsp.ts +2 -1
- package/external/pi-tools-suite/src/lsp/_shared/output.ts +8 -7
- package/external/pi-tools-suite/src/lsp/manager.ts +4 -4
- package/external/pi-tools-suite/src/repo-discovery/index.ts +49 -2
- package/external/pi-tools-suite/src/todo/tool/response-envelope.ts +9 -1
- package/external/pi-tools-suite/src/tool-descriptions.ts +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { streamSimple } from "@earendil-works/pi-ai";
|
|
2
|
+
import { isRecord } from "../guards.js";
|
|
3
|
+
import { parseModelRef } from "../model/model-ref.js";
|
|
4
|
+
const AUTOCOMPLETE_DEBOUNCE_MS = 350;
|
|
5
|
+
const AUTOCOMPLETE_MIN_TEXT_LENGTH = 3;
|
|
6
|
+
const AUTOCOMPLETE_TIMEOUT_MS = 3_000;
|
|
7
|
+
const AUTOCOMPLETE_MAX_TOKENS = 48;
|
|
8
|
+
const AUTOCOMPLETE_MAX_PROMPT_TOKENS = 1_200;
|
|
9
|
+
const AUTOCOMPLETE_INCLUDE_RECENT_MESSAGES = 0;
|
|
10
|
+
const AUTOCOMPLETE_MAX_SUFFIX_LENGTH = 320;
|
|
11
|
+
const AUTOCOMPLETE_HISTORY_MESSAGE_MAX_CHARS = 700;
|
|
12
|
+
const AUTOCOMPLETE_HISTORY_CONTEXT_MAX_CHARS = 3_600;
|
|
13
|
+
const AUTOCOMPLETE_TOKEN_CHARS = 4;
|
|
14
|
+
const AUTOCOMPLETE_SYSTEM_PROMPT = `You are an inline autocomplete engine for pix, a terminal UI for a coding agent.
|
|
15
|
+
Use provided recent active-session messages only as optional context; the current draft is the source of truth.
|
|
16
|
+
Continue only the user's current draft at the cursor.
|
|
17
|
+
Output only the exact suffix to append after the draft.
|
|
18
|
+
Do not repeat the draft. Do not answer the user. Do not explain.
|
|
19
|
+
If the draft already looks complete or the continuation is uncertain, output an empty string.
|
|
20
|
+
Keep the suffix short, in the user's language/style, and stop at a natural boundary.`;
|
|
21
|
+
export class AppAutocompleteController {
|
|
22
|
+
host;
|
|
23
|
+
timer;
|
|
24
|
+
lastObservedKey = "";
|
|
25
|
+
requestSeq = 0;
|
|
26
|
+
suggestion;
|
|
27
|
+
activeAbortController;
|
|
28
|
+
completeInputWithPi;
|
|
29
|
+
debounceOverrideMs;
|
|
30
|
+
constructor(host, options = {}) {
|
|
31
|
+
this.host = host;
|
|
32
|
+
this.completeInputWithPi = options.completeInputWithPi ?? completeInputWithPi;
|
|
33
|
+
this.debounceOverrideMs = options.debounceMs;
|
|
34
|
+
}
|
|
35
|
+
observeInput() {
|
|
36
|
+
const target = this.currentTarget();
|
|
37
|
+
const key = target ? this.targetKey(target) : "";
|
|
38
|
+
if (key === this.lastObservedKey)
|
|
39
|
+
return;
|
|
40
|
+
this.lastObservedKey = key;
|
|
41
|
+
this.suggestion = undefined;
|
|
42
|
+
this.clearTimer();
|
|
43
|
+
this.cancelInFlight();
|
|
44
|
+
if (!target)
|
|
45
|
+
return;
|
|
46
|
+
const requestSeq = ++this.requestSeq;
|
|
47
|
+
this.timer = setTimeout(() => {
|
|
48
|
+
void this.runAutocomplete(target, requestSeq);
|
|
49
|
+
}, this.currentDebounceMs());
|
|
50
|
+
this.timer.unref?.();
|
|
51
|
+
}
|
|
52
|
+
suggestionText() {
|
|
53
|
+
const target = this.currentTarget();
|
|
54
|
+
if (!target || !this.suggestion)
|
|
55
|
+
return undefined;
|
|
56
|
+
return this.sameTarget(target, this.suggestion.target) ? this.suggestion.text : undefined;
|
|
57
|
+
}
|
|
58
|
+
acceptSuggestion() {
|
|
59
|
+
const suggestion = this.suggestionText();
|
|
60
|
+
if (!suggestion)
|
|
61
|
+
return false;
|
|
62
|
+
this.host.inputEditor().insert(suggestion);
|
|
63
|
+
this.suggestion = undefined;
|
|
64
|
+
this.lastObservedKey = "";
|
|
65
|
+
this.clearTimer();
|
|
66
|
+
this.host.render();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
dispose() {
|
|
70
|
+
this.clearTimer();
|
|
71
|
+
this.cancelInFlight();
|
|
72
|
+
this.requestSeq += 1;
|
|
73
|
+
this.suggestion = undefined;
|
|
74
|
+
}
|
|
75
|
+
async runAutocomplete(target, requestSeq) {
|
|
76
|
+
const runtime = this.host.runtime();
|
|
77
|
+
const config = { ...this.host.autocompleteConfig() };
|
|
78
|
+
if (!runtime || !config.modelRef.trim())
|
|
79
|
+
return;
|
|
80
|
+
const abortController = new AbortController();
|
|
81
|
+
this.activeAbortController = abortController;
|
|
82
|
+
try {
|
|
83
|
+
const completion = await this.completeInputWithPi(runtime, target.text, config, abortController.signal);
|
|
84
|
+
if (requestSeq !== this.requestSeq)
|
|
85
|
+
return;
|
|
86
|
+
const current = this.currentTarget();
|
|
87
|
+
if (!current || !this.sameTarget(current, target))
|
|
88
|
+
return;
|
|
89
|
+
const suggestion = cleanupCompletion(completion, target.text, config);
|
|
90
|
+
if (!suggestion)
|
|
91
|
+
return;
|
|
92
|
+
this.suggestion = { target, text: suggestion };
|
|
93
|
+
if (this.host.isRunning())
|
|
94
|
+
this.host.render();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Inline autocomplete is best-effort; avoid surfacing transient model/auth errors while typing.
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (this.activeAbortController === abortController)
|
|
101
|
+
this.activeAbortController = undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
currentTarget() {
|
|
105
|
+
const config = this.host.autocompleteConfig();
|
|
106
|
+
if (!config.modelRef.trim())
|
|
107
|
+
return undefined;
|
|
108
|
+
const editor = this.host.inputEditor();
|
|
109
|
+
const text = editor.text;
|
|
110
|
+
const cursor = editor.cursor;
|
|
111
|
+
if (editor.hasSelection || editor.hasAttachments)
|
|
112
|
+
return undefined;
|
|
113
|
+
if (cursor !== text.length)
|
|
114
|
+
return undefined;
|
|
115
|
+
if (text.trim().length < AUTOCOMPLETE_MIN_TEXT_LENGTH)
|
|
116
|
+
return undefined;
|
|
117
|
+
if (text.startsWith("/") || text.startsWith("!"))
|
|
118
|
+
return undefined;
|
|
119
|
+
return { text, cursor };
|
|
120
|
+
}
|
|
121
|
+
targetKey(target) {
|
|
122
|
+
return `${target.cursor}\u0000${target.text}`;
|
|
123
|
+
}
|
|
124
|
+
sameTarget(a, b) {
|
|
125
|
+
return a.cursor === b.cursor && a.text === b.text;
|
|
126
|
+
}
|
|
127
|
+
currentDebounceMs() {
|
|
128
|
+
return numberInRange(this.debounceOverrideMs ?? this.host.autocompleteConfig().debounceMs, AUTOCOMPLETE_DEBOUNCE_MS, 0, 5_000);
|
|
129
|
+
}
|
|
130
|
+
clearTimer() {
|
|
131
|
+
if (!this.timer)
|
|
132
|
+
return;
|
|
133
|
+
clearTimeout(this.timer);
|
|
134
|
+
this.timer = undefined;
|
|
135
|
+
}
|
|
136
|
+
cancelInFlight() {
|
|
137
|
+
this.activeAbortController?.abort();
|
|
138
|
+
this.activeAbortController = undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export async function completeInputWithPi(runtime, draft, config, signal) {
|
|
142
|
+
const parsedModel = parseModelRef(config.modelRef);
|
|
143
|
+
const registry = runtime.services.modelRegistry;
|
|
144
|
+
let model = registry.find(parsedModel.provider, parsedModel.modelId);
|
|
145
|
+
if (!model) {
|
|
146
|
+
registry.refresh();
|
|
147
|
+
model = registry.find(parsedModel.provider, parsedModel.modelId);
|
|
148
|
+
}
|
|
149
|
+
if (!model)
|
|
150
|
+
throw new Error(`Model not found: ${parsedModel.provider}/${parsedModel.modelId}`);
|
|
151
|
+
const auth = await registry.getApiKeyAndHeaders(model);
|
|
152
|
+
if (!auth.ok)
|
|
153
|
+
throw new Error(auth.error);
|
|
154
|
+
const timeoutMs = numberInRange(config.timeoutMs, AUTOCOMPLETE_TIMEOUT_MS, 250, 10_000);
|
|
155
|
+
const maxTokens = numberInRange(config.maxTokens, AUTOCOMPLETE_MAX_TOKENS, 8, 256);
|
|
156
|
+
const maxPromptTokens = numberInRange(config.maxPromptTokens, AUTOCOMPLETE_MAX_PROMPT_TOKENS, 256, 16_000);
|
|
157
|
+
const requestSignal = createTimeoutSignal(signal, timeoutMs);
|
|
158
|
+
const requestMaxTokens = model.maxTokens > 0 ? Math.min(model.maxTokens, maxTokens) : maxTokens;
|
|
159
|
+
const requestModel = { ...model, maxTokens: requestMaxTokens };
|
|
160
|
+
const includeRecentMessages = numberInRange(config.includeRecentMessages, AUTOCOMPLETE_INCLUDE_RECENT_MESSAGES, 0, 20);
|
|
161
|
+
const history = includeRecentMessages > 0 ? autocompleteHistoryFromMessages(runtime.session.messages, includeRecentMessages) : [];
|
|
162
|
+
const prompt = buildAutocompletePrompt({ cwd: runtime.cwd, draft, history, maxPromptTokens });
|
|
163
|
+
if (!prompt)
|
|
164
|
+
return "";
|
|
165
|
+
let output = "";
|
|
166
|
+
let streamError;
|
|
167
|
+
try {
|
|
168
|
+
const stream = streamSimple(requestModel, {
|
|
169
|
+
systemPrompt: AUTOCOMPLETE_SYSTEM_PROMPT,
|
|
170
|
+
messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
|
|
171
|
+
}, {
|
|
172
|
+
...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
|
|
173
|
+
...(auth.headers === undefined ? {} : { headers: auth.headers }),
|
|
174
|
+
cacheRetention: "none",
|
|
175
|
+
maxRetryDelayMs: 0,
|
|
176
|
+
maxRetries: 0,
|
|
177
|
+
maxTokens: requestModel.maxTokens,
|
|
178
|
+
...(parsedModel.thinkingLevel && parsedModel.thinkingLevel !== "off" ? { reasoning: parsedModel.thinkingLevel } : {}),
|
|
179
|
+
signal: requestSignal.signal,
|
|
180
|
+
temperature: 0.1,
|
|
181
|
+
timeoutMs,
|
|
182
|
+
});
|
|
183
|
+
for await (const event of stream) {
|
|
184
|
+
if (event.type === "text_delta")
|
|
185
|
+
output += event.delta;
|
|
186
|
+
else if (event.type === "done" && !output)
|
|
187
|
+
output = assistantMessageText(event.message);
|
|
188
|
+
else if (event.type === "error")
|
|
189
|
+
streamError = event.error.errorMessage ?? event.reason;
|
|
190
|
+
}
|
|
191
|
+
if (streamError)
|
|
192
|
+
throw new Error(streamError);
|
|
193
|
+
return output;
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
requestSignal.dispose();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export function autocompleteHistoryFromMessages(messages, includeRecentMessages) {
|
|
200
|
+
const limit = numberInRange(includeRecentMessages, AUTOCOMPLETE_INCLUDE_RECENT_MESSAGES, 0, 20);
|
|
201
|
+
if (limit <= 0)
|
|
202
|
+
return [];
|
|
203
|
+
const history = [];
|
|
204
|
+
for (let index = messages.length - 1; index >= 0 && history.length < limit; index -= 1) {
|
|
205
|
+
const message = messages[index];
|
|
206
|
+
if (!isRecord(message))
|
|
207
|
+
continue;
|
|
208
|
+
const role = message.role === "user" || message.role === "assistant" ? message.role : undefined;
|
|
209
|
+
if (!role)
|
|
210
|
+
continue;
|
|
211
|
+
const text = compactHistoryText(messageText(message, role));
|
|
212
|
+
if (!text)
|
|
213
|
+
continue;
|
|
214
|
+
history.push({ role, text: clipHistoryText(text, AUTOCOMPLETE_HISTORY_MESSAGE_MAX_CHARS) });
|
|
215
|
+
}
|
|
216
|
+
return trimHistoryContext(history.reverse(), AUTOCOMPLETE_HISTORY_CONTEXT_MAX_CHARS);
|
|
217
|
+
}
|
|
218
|
+
export function buildAutocompletePrompt(input) {
|
|
219
|
+
const maxPromptTokens = numberInRange(input.maxPromptTokens, AUTOCOMPLETE_MAX_PROMPT_TOKENS, 256, 16_000);
|
|
220
|
+
let history = input.history.slice();
|
|
221
|
+
let prompt = renderAutocompletePrompt({ ...input, history });
|
|
222
|
+
while (history.length > 0 && autocompletePromptTokenEstimate(prompt) > maxPromptTokens) {
|
|
223
|
+
history = history.slice(1);
|
|
224
|
+
prompt = renderAutocompletePrompt({ ...input, history });
|
|
225
|
+
}
|
|
226
|
+
return autocompletePromptTokenEstimate(prompt) <= maxPromptTokens ? prompt : "";
|
|
227
|
+
}
|
|
228
|
+
function renderAutocompletePrompt(input) {
|
|
229
|
+
const lines = [
|
|
230
|
+
"Complete the current terminal input for the active pix/pi coding-agent session.",
|
|
231
|
+
`cwd: ${input.cwd}`,
|
|
232
|
+
];
|
|
233
|
+
if (input.history.length > 0) {
|
|
234
|
+
lines.push("", "Recent messages are context only; never continue them directly.", "<recent-active-session-messages>", formatAutocompleteHistory(input.history), "</recent-active-session-messages>");
|
|
235
|
+
}
|
|
236
|
+
return [
|
|
237
|
+
...lines,
|
|
238
|
+
"",
|
|
239
|
+
"Return only the suffix to append after <cursor>. Return nothing if unsure.",
|
|
240
|
+
"<draft>",
|
|
241
|
+
input.draft,
|
|
242
|
+
"<cursor>",
|
|
243
|
+
"</draft>",
|
|
244
|
+
].join("\n");
|
|
245
|
+
}
|
|
246
|
+
export function autocompletePromptTokenEstimate(prompt, systemPrompt = AUTOCOMPLETE_SYSTEM_PROMPT) {
|
|
247
|
+
return estimateTextTokens(systemPrompt) + estimateTextTokens(prompt);
|
|
248
|
+
}
|
|
249
|
+
function estimateTextTokens(text) {
|
|
250
|
+
return Math.ceil(text.length / AUTOCOMPLETE_TOKEN_CHARS);
|
|
251
|
+
}
|
|
252
|
+
export function cleanupCompletion(output, draft, config) {
|
|
253
|
+
let text = output.replace(/\r\n/gu, "\n").trimEnd();
|
|
254
|
+
const fenced = /^```[^\n`]*\n([\s\S]*?)\n```$/u.exec(text.trim());
|
|
255
|
+
if (fenced)
|
|
256
|
+
text = fenced[1].trimEnd();
|
|
257
|
+
if (text.startsWith(draft))
|
|
258
|
+
text = text.slice(draft.length);
|
|
259
|
+
text = text
|
|
260
|
+
.replace(/^<cursor>/iu, "")
|
|
261
|
+
.replace(/^\s*(?:completion|suffix|autocomplete|продолжение)\s*:\s*/iu, "")
|
|
262
|
+
.replace(/^\n+/u, "");
|
|
263
|
+
if (!text.trim())
|
|
264
|
+
return "";
|
|
265
|
+
const maxTokens = numberInRange(config?.maxTokens, AUTOCOMPLETE_MAX_TOKENS, 8, 256);
|
|
266
|
+
const maxChars = Math.min(AUTOCOMPLETE_MAX_SUFFIX_LENGTH, maxTokens * 8);
|
|
267
|
+
return text.slice(0, maxChars);
|
|
268
|
+
}
|
|
269
|
+
function formatAutocompleteHistory(history) {
|
|
270
|
+
if (history.length === 0)
|
|
271
|
+
return "(no previous user/assistant messages in this active session)";
|
|
272
|
+
return history.map((message) => [
|
|
273
|
+
`<message role="${message.role}">`,
|
|
274
|
+
message.text.replace(/<\/message>/giu, "</ message>"),
|
|
275
|
+
"</message>",
|
|
276
|
+
].join("\n")).join("\n\n");
|
|
277
|
+
}
|
|
278
|
+
function assistantMessageText(message) {
|
|
279
|
+
return message.content
|
|
280
|
+
.flatMap((content) => content.type === "text" ? [content.text] : [])
|
|
281
|
+
.join("\n");
|
|
282
|
+
}
|
|
283
|
+
function messageText(message, role) {
|
|
284
|
+
return contentText(message.content, { includeImages: role === "user" });
|
|
285
|
+
}
|
|
286
|
+
function contentText(content, options) {
|
|
287
|
+
if (typeof content === "string")
|
|
288
|
+
return content;
|
|
289
|
+
if (!Array.isArray(content))
|
|
290
|
+
return "";
|
|
291
|
+
return content.flatMap((part) => {
|
|
292
|
+
if (!isRecord(part) || typeof part.type !== "string")
|
|
293
|
+
return [];
|
|
294
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
295
|
+
return [part.text];
|
|
296
|
+
if (part.type === "image" && options.includeImages)
|
|
297
|
+
return ["[image]"];
|
|
298
|
+
return [];
|
|
299
|
+
}).join("\n");
|
|
300
|
+
}
|
|
301
|
+
function compactHistoryText(text) {
|
|
302
|
+
return text
|
|
303
|
+
.replace(/\r\n/gu, "\n")
|
|
304
|
+
.split("\n")
|
|
305
|
+
.filter((line) => !isMarkdownReferenceDefinition(line))
|
|
306
|
+
.join("\n")
|
|
307
|
+
.replace(/[\t ]+/gu, " ")
|
|
308
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
309
|
+
.trim();
|
|
310
|
+
}
|
|
311
|
+
function clipHistoryText(text, maxChars) {
|
|
312
|
+
if (text.length <= maxChars)
|
|
313
|
+
return text;
|
|
314
|
+
const headLength = Math.floor((maxChars - 3) / 2);
|
|
315
|
+
const tailLength = maxChars - 3 - headLength;
|
|
316
|
+
return `${text.slice(0, headLength).trimEnd()}\n…\n${text.slice(-tailLength).trimStart()}`;
|
|
317
|
+
}
|
|
318
|
+
function trimHistoryContext(history, maxChars) {
|
|
319
|
+
const trimmed = history.slice();
|
|
320
|
+
while (trimmed.length > 0 && historyContextChars(trimmed) > maxChars)
|
|
321
|
+
trimmed.shift();
|
|
322
|
+
return trimmed;
|
|
323
|
+
}
|
|
324
|
+
function historyContextChars(history) {
|
|
325
|
+
return history.reduce((sum, message) => sum + message.text.length + message.role.length + 32, 0);
|
|
326
|
+
}
|
|
327
|
+
function isMarkdownReferenceDefinition(line) {
|
|
328
|
+
return /^ {0,3}\[[^\]\n]+\]:[ \t]*\S.*$/u.test(line);
|
|
329
|
+
}
|
|
330
|
+
function numberInRange(value, fallback, min, max) {
|
|
331
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
332
|
+
return fallback;
|
|
333
|
+
const rounded = Math.round(value);
|
|
334
|
+
return Math.min(max, Math.max(min, rounded));
|
|
335
|
+
}
|
|
336
|
+
function createTimeoutSignal(parent, timeoutMs) {
|
|
337
|
+
const abortController = new AbortController();
|
|
338
|
+
const abort = () => abortController.abort();
|
|
339
|
+
if (parent?.aborted)
|
|
340
|
+
abort();
|
|
341
|
+
else
|
|
342
|
+
parent?.addEventListener("abort", abort, { once: true });
|
|
343
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
344
|
+
timer.unref?.();
|
|
345
|
+
return {
|
|
346
|
+
signal: abortController.signal,
|
|
347
|
+
dispose: () => {
|
|
348
|
+
clearTimeout(timer);
|
|
349
|
+
parent?.removeEventListener("abort", abort);
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -36,6 +36,7 @@ export declare class AppInputActionController {
|
|
|
36
36
|
private abortInFlight;
|
|
37
37
|
constructor(host: AppInputActionControllerHost, popupMenus: AppPopupMenuController, popupActions: AppPopupActionController, queuedMessages: AppQueuedMessageController);
|
|
38
38
|
handleEnter(): void;
|
|
39
|
+
queueInputFromEditor(): Promise<void>;
|
|
39
40
|
handleInterrupt(): Promise<void>;
|
|
40
41
|
handleEscape(): Promise<void>;
|
|
41
42
|
private abortStreamingSession;
|
|
@@ -21,6 +21,27 @@ export class AppInputActionController {
|
|
|
21
21
|
}
|
|
22
22
|
void this.submitInput();
|
|
23
23
|
}
|
|
24
|
+
async queueInputFromEditor() {
|
|
25
|
+
await this.host.stopVoiceInput();
|
|
26
|
+
if (this.popupMenus.syncActivePopupMenu())
|
|
27
|
+
this.popupMenus.cancelActivePopupMenu();
|
|
28
|
+
const inputEditor = this.host.inputEditor();
|
|
29
|
+
const rawPromptText = inputEditor.promptText;
|
|
30
|
+
const rawDisplayText = inputEditor.expandedText;
|
|
31
|
+
const promptText = rawPromptText.trimEnd();
|
|
32
|
+
const displayText = rawDisplayText.trimEnd();
|
|
33
|
+
const images = [...inputEditor.images];
|
|
34
|
+
if (!promptText && images.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
const message = this.queuedMessages.createSubmittedUserMessage(promptText, displayText, images);
|
|
37
|
+
this.host.requestHistory().add(message.displayText);
|
|
38
|
+
inputEditor.clear();
|
|
39
|
+
await this.host.clearPersistedInputDraft();
|
|
40
|
+
this.host.render();
|
|
41
|
+
this.queuedMessages.deferUserMessage(message);
|
|
42
|
+
if (this.host.isRunning())
|
|
43
|
+
this.host.render();
|
|
44
|
+
}
|
|
24
45
|
async handleInterrupt() {
|
|
25
46
|
if (this.host.interruptShellCommand()) {
|
|
26
47
|
this.host.inputEditor().clear();
|
|
@@ -24,6 +24,7 @@ export type InputControllerHost = {
|
|
|
24
24
|
handleDirectPopupInput(char: string): boolean;
|
|
25
25
|
autocompleteModel(): boolean;
|
|
26
26
|
autocompleteThinking(): boolean;
|
|
27
|
+
acceptAutocompleteSuggestion(): boolean;
|
|
27
28
|
autocompleteSlashCommand(): void;
|
|
28
29
|
toggleVoiceRecording(): void;
|
|
29
30
|
stop(): Promise<void>;
|
|
@@ -357,6 +357,8 @@ export class AppInputController {
|
|
|
357
357
|
if (char === "\t") {
|
|
358
358
|
if (this.host.getDirectPopupMenu() === "sdk-menu")
|
|
359
359
|
return;
|
|
360
|
+
if (this.host.acceptAutocompleteSuggestion())
|
|
361
|
+
return;
|
|
360
362
|
if (this.host.autocompleteModel())
|
|
361
363
|
return;
|
|
362
364
|
if (this.host.autocompleteThinking())
|
|
@@ -4,6 +4,7 @@ import { isAbsolute, relative, resolve } from "node:path";
|
|
|
4
4
|
import { isImagePath, looksLikeFilePath, quoteFilePathForInput, readClipboardImage } from "../../input-editor.js";
|
|
5
5
|
import { PASTE_DUPLICATE_WINDOW_MS } from "../constants.js";
|
|
6
6
|
import { normalizePastedTextForDuplicateKey } from "../rendering/render-text.js";
|
|
7
|
+
const PASTE_FINGERPRINT_PREFIX_CHARS = 64 * 1024;
|
|
7
8
|
export class InputPasteHandler {
|
|
8
9
|
host;
|
|
9
10
|
pasteBuffer = "";
|
|
@@ -23,13 +24,7 @@ export class InputPasteHandler {
|
|
|
23
24
|
return true;
|
|
24
25
|
}
|
|
25
26
|
if (!this.host.inputEditor.isInBracketedPaste && this.isPlainMultilinePasteChunk(data)) {
|
|
26
|
-
|
|
27
|
-
this.host.render();
|
|
28
|
-
return true;
|
|
29
|
-
}
|
|
30
|
-
this.host.resetRequestHistoryNavigation();
|
|
31
|
-
this.host.inputEditor.attachPastedText(data);
|
|
32
|
-
this.host.render();
|
|
27
|
+
this.schedulePastedText(data);
|
|
33
28
|
return true;
|
|
34
29
|
}
|
|
35
30
|
return false;
|
|
@@ -43,7 +38,9 @@ export class InputPasteHandler {
|
|
|
43
38
|
}
|
|
44
39
|
endBracketedPaste() {
|
|
45
40
|
this.host.inputEditor.endBracketedPaste();
|
|
46
|
-
this.
|
|
41
|
+
const text = this.pasteBuffer;
|
|
42
|
+
this.pasteBuffer = "";
|
|
43
|
+
this.handlePasteEnd(text);
|
|
47
44
|
}
|
|
48
45
|
async handleClipboardImagePaste() {
|
|
49
46
|
const image = await readClipboardImage();
|
|
@@ -83,16 +80,17 @@ export class InputPasteHandler {
|
|
|
83
80
|
this.recentPasteFingerprints.delete(fingerprint);
|
|
84
81
|
}
|
|
85
82
|
const normalizedPayload = kind === "text" ? normalizePastedTextForDuplicateKey(payload) : payload;
|
|
86
|
-
const
|
|
83
|
+
const fingerprintPayload = normalizedPayload.length > PASTE_FINGERPRINT_PREFIX_CHARS
|
|
84
|
+
? `${normalizedPayload.length}:${normalizedPayload.slice(0, PASTE_FINGERPRINT_PREFIX_CHARS)}`
|
|
85
|
+
: normalizedPayload;
|
|
86
|
+
const fingerprint = `${kind}:${createHash("sha256").update(fingerprintPayload).digest("hex")}`;
|
|
87
87
|
const previousTimestamp = this.recentPasteFingerprints.get(fingerprint);
|
|
88
88
|
if (previousTimestamp !== undefined && now - previousTimestamp <= PASTE_DUPLICATE_WINDOW_MS)
|
|
89
89
|
return true;
|
|
90
90
|
this.recentPasteFingerprints.set(fingerprint, now);
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
|
-
handlePasteEnd() {
|
|
94
|
-
const text = this.pasteBuffer;
|
|
95
|
-
this.pasteBuffer = "";
|
|
93
|
+
handlePasteEnd(text) {
|
|
96
94
|
if (!text)
|
|
97
95
|
return;
|
|
98
96
|
const filePath = this.plainPasteFilePath(text);
|
|
@@ -104,13 +102,19 @@ export class InputPasteHandler {
|
|
|
104
102
|
void this.handleFilePaste(filePath);
|
|
105
103
|
return;
|
|
106
104
|
}
|
|
107
|
-
|
|
105
|
+
this.schedulePastedText(text);
|
|
106
|
+
}
|
|
107
|
+
schedulePastedText(text) {
|
|
108
|
+
const timer = setTimeout(() => {
|
|
109
|
+
if (this.isDuplicatePaste("text", text)) {
|
|
110
|
+
this.host.render();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
this.host.resetRequestHistoryNavigation();
|
|
114
|
+
this.host.inputEditor.attachPastedText(text);
|
|
108
115
|
this.host.render();
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
this.host.resetRequestHistoryNavigation();
|
|
112
|
-
this.host.inputEditor.attachPastedText(text);
|
|
113
|
-
this.host.render();
|
|
116
|
+
}, 0);
|
|
117
|
+
timer.unref?.();
|
|
114
118
|
}
|
|
115
119
|
async handleFilePaste(filePath) {
|
|
116
120
|
const inputPath = await this.filePathForInput(filePath);
|
|
@@ -22,6 +22,7 @@ export declare class AppVoiceController {
|
|
|
22
22
|
private progressTimer;
|
|
23
23
|
private lastSystemProgressMessage;
|
|
24
24
|
private partialTranscript;
|
|
25
|
+
private partialTranscriptTimer;
|
|
25
26
|
private startGeneration;
|
|
26
27
|
constructor(host: AppVoiceControllerHost, dictationConfig: DictationConfig);
|
|
27
28
|
statusWidgetText(): string;
|
|
@@ -47,6 +48,7 @@ export declare class AppVoiceController {
|
|
|
47
48
|
private emitTranscript;
|
|
48
49
|
private emitPartialTranscript;
|
|
49
50
|
private clearPartialTranscript;
|
|
51
|
+
private schedulePartialTranscriptEmit;
|
|
50
52
|
private isCurrentStart;
|
|
51
53
|
private isCurrentAudioProcess;
|
|
52
54
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
2
|
import { createWriteStream } from "node:fs";
|
|
3
3
|
import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import http from "node:http";
|
|
@@ -8,12 +8,14 @@ import { join } from "node:path";
|
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { savePixDictationLanguage } from "../../config.js";
|
|
10
10
|
import { APP_ICONS } from "../icons.js";
|
|
11
|
+
import { commandExists } from "../process.js";
|
|
11
12
|
const SAMPLE_RATE = 16_000;
|
|
12
13
|
const require = createRequire(import.meta.url);
|
|
13
14
|
const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
14
15
|
const modelsRoot = join(projectRoot, "models", "vosk");
|
|
15
16
|
const VOSK_PACKAGE_SPEC = "vosk@0.3.39";
|
|
16
17
|
const VOICE_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
18
|
+
const VOICE_PARTIAL_TRANSCRIPT_THROTTLE_MS = 100;
|
|
17
19
|
let voskInstallPromise;
|
|
18
20
|
export class AppVoiceController {
|
|
19
21
|
host;
|
|
@@ -29,6 +31,7 @@ export class AppVoiceController {
|
|
|
29
31
|
progressTimer;
|
|
30
32
|
lastSystemProgressMessage;
|
|
31
33
|
partialTranscript;
|
|
34
|
+
partialTranscriptTimer;
|
|
32
35
|
startGeneration = 0;
|
|
33
36
|
constructor(host, dictationConfig) {
|
|
34
37
|
this.host = host;
|
|
@@ -128,7 +131,7 @@ export class AppVoiceController {
|
|
|
128
131
|
this.state = "loading";
|
|
129
132
|
this.host.render();
|
|
130
133
|
const model = this.cachedModel(language, modelPath, vosk);
|
|
131
|
-
const recorder = selectRecorderCommand();
|
|
134
|
+
const recorder = await selectRecorderCommand();
|
|
132
135
|
const recognizer = new vosk.Recognizer({ model, sampleRate: SAMPLE_RATE });
|
|
133
136
|
const audioProcess = spawn(recorder.command, recorder.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
134
137
|
this.recognizer = recognizer;
|
|
@@ -283,14 +286,27 @@ export class AppVoiceController {
|
|
|
283
286
|
if (text === this.partialTranscript)
|
|
284
287
|
return;
|
|
285
288
|
this.partialTranscript = text;
|
|
286
|
-
this.
|
|
289
|
+
this.schedulePartialTranscriptEmit();
|
|
287
290
|
}
|
|
288
291
|
clearPartialTranscript() {
|
|
289
292
|
if (!this.partialTranscript)
|
|
290
293
|
return;
|
|
291
294
|
this.partialTranscript = undefined;
|
|
295
|
+
if (this.partialTranscriptTimer) {
|
|
296
|
+
clearTimeout(this.partialTranscriptTimer);
|
|
297
|
+
this.partialTranscriptTimer = undefined;
|
|
298
|
+
}
|
|
292
299
|
this.host.setPartialTranscript(undefined);
|
|
293
300
|
}
|
|
301
|
+
schedulePartialTranscriptEmit() {
|
|
302
|
+
if (this.partialTranscriptTimer)
|
|
303
|
+
return;
|
|
304
|
+
this.partialTranscriptTimer = setTimeout(() => {
|
|
305
|
+
this.partialTranscriptTimer = undefined;
|
|
306
|
+
this.host.setPartialTranscript(this.partialTranscript);
|
|
307
|
+
}, VOICE_PARTIAL_TRANSCRIPT_THROTTLE_MS);
|
|
308
|
+
this.partialTranscriptTimer.unref?.();
|
|
309
|
+
}
|
|
294
310
|
isCurrentStart(generation) {
|
|
295
311
|
return this.startGeneration === generation;
|
|
296
312
|
}
|
|
@@ -367,11 +383,11 @@ async function downloadFile(url, destination, redirects = 3) {
|
|
|
367
383
|
});
|
|
368
384
|
}
|
|
369
385
|
async function extractZip(zipPath, destination) {
|
|
370
|
-
if (commandExists("unzip")) {
|
|
386
|
+
if (await commandExists("unzip")) {
|
|
371
387
|
await runCommand("unzip", ["-q", zipPath, "-d", destination]);
|
|
372
388
|
return;
|
|
373
389
|
}
|
|
374
|
-
if (process.platform === "darwin" && commandExists("ditto")) {
|
|
390
|
+
if (process.platform === "darwin" && await commandExists("ditto")) {
|
|
375
391
|
await runCommand("ditto", ["-x", "-k", zipPath, destination]);
|
|
376
392
|
return;
|
|
377
393
|
}
|
|
@@ -535,7 +551,7 @@ function isVoskModule(value) {
|
|
|
535
551
|
const record = value;
|
|
536
552
|
return typeof record.Model === "function" && typeof record.Recognizer === "function";
|
|
537
553
|
}
|
|
538
|
-
function selectRecorderCommand() {
|
|
554
|
+
async function selectRecorderCommand() {
|
|
539
555
|
const commands = [
|
|
540
556
|
{
|
|
541
557
|
command: "rec",
|
|
@@ -566,15 +582,11 @@ function selectRecorderCommand() {
|
|
|
566
582
|
description: "arecord",
|
|
567
583
|
});
|
|
568
584
|
}
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
function commandExists(command) {
|
|
575
|
-
if (process.platform === "win32")
|
|
576
|
-
return spawnSync("where", [command], { stdio: "ignore" }).status === 0;
|
|
577
|
-
return spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" }).status === 0;
|
|
585
|
+
for (const candidate of commands) {
|
|
586
|
+
if (await commandExists(candidate.command))
|
|
587
|
+
return candidate;
|
|
588
|
+
}
|
|
589
|
+
throw new Error("audio recorder not found: install SoX (`rec`/`sox`), ffmpeg, or arecord");
|
|
578
590
|
}
|
|
579
591
|
function transcriptText(result) {
|
|
580
592
|
const parsed = typeof result === "string" ? parseResultString(result) : result;
|
|
@@ -79,9 +79,18 @@ export type OpenAIUsageResponse = {
|
|
|
79
79
|
rate_limit: OpenAIRateLimit | null;
|
|
80
80
|
additional_rate_limits?: OpenAIAdditionalRateLimit[];
|
|
81
81
|
};
|
|
82
|
+
type AntigravityCachedQuotaBucket = {
|
|
83
|
+
remainingFraction?: number;
|
|
84
|
+
resetTime?: string;
|
|
85
|
+
modelCount?: number;
|
|
86
|
+
};
|
|
87
|
+
type AntigravityCachedQuota = Record<string, AntigravityCachedQuotaBucket | undefined>;
|
|
82
88
|
type AntigravityQuotaAccount = {
|
|
83
89
|
readonly email?: string;
|
|
84
90
|
readonly refreshToken: string;
|
|
91
|
+
readonly accessToken?: string;
|
|
92
|
+
readonly cachedQuota?: AntigravityCachedQuota;
|
|
93
|
+
readonly cachedQuotaUpdatedAt?: number;
|
|
85
94
|
readonly projectId: string;
|
|
86
95
|
readonly accountIndex?: number;
|
|
87
96
|
readonly accountCount?: number;
|