@npv12/opencode-mini-session 0.0.1
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 +77 -0
- package/dist/agent.js +103 -0
- package/dist/components/ActionButton.js +39 -0
- package/dist/components/AnswerDialog.js +841 -0
- package/dist/components/HintBar.js +34 -0
- package/dist/config.js +27 -0
- package/dist/constants.js +24 -0
- package/dist/context.js +72 -0
- package/dist/counter.js +55 -0
- package/dist/diagnostics.js +59 -0
- package/dist/index.js +157 -0
- package/dist/keybinds.js +152 -0
- package/dist/model.js +75 -0
- package/dist/routing.js +20 -0
- package/dist/session.js +617 -0
- package/dist/theme.js +34 -0
- package/dist/types.js +1 -0
- package/package.json +42 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
import { buildFooterCounterState } from "./counter.js";
|
|
2
|
+
import { buildMiniPreamble, buildSessionCreatePayload, buildMiniErrorDetail, formatMiniNotice, resolveRuntimeMiniAgent } from "./agent.js";
|
|
3
|
+
import { getSessionEntries, buildCopiedContext } from "./context.js";
|
|
4
|
+
import { getErrorMessage } from "./diagnostics.js";
|
|
5
|
+
import { resolveDefaultModel, formatResolvedModel, resolveModelContextWindow } from "./model.js";
|
|
6
|
+
export function openMiniSession(context, config, mode, setOverlay, active, modelPreference, thinkingPreference, openPickerFn) {
|
|
7
|
+
const currentRoute = context.ui.router.current();
|
|
8
|
+
if (currentRoute.type !== "session") {
|
|
9
|
+
context.ui.toast.show({
|
|
10
|
+
variant: "error",
|
|
11
|
+
message: "mini only works inside a session."
|
|
12
|
+
});
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
if (active.get()) {
|
|
16
|
+
active.get().show();
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
void startQuestion(context, config, mode, currentRoute.sessionID, setOverlay, active, modelPreference, thinkingPreference, openPickerFn);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
export async function startQuestion(context, config, mode, originSessionID, setOverlay, active, modelPreference, thinkingPreference, openPickerFn) {
|
|
23
|
+
const entries = getSessionEntries(context, originSessionID);
|
|
24
|
+
const copiedContext = mode === "main" ? buildCopiedContext(entries, config.tokenLimit) : {
|
|
25
|
+
text: "",
|
|
26
|
+
usedTokens: undefined,
|
|
27
|
+
totalAvailableTokens: undefined
|
|
28
|
+
};
|
|
29
|
+
const contextText = copiedContext.text;
|
|
30
|
+
const sessionInfo = context.data.session.get(originSessionID);
|
|
31
|
+
const models = context.data.location.model.list(sessionInfo?.location);
|
|
32
|
+
const providers = context.data.location.provider.list(sessionInfo?.location);
|
|
33
|
+
const defaultResolvedModel = resolveDefaultModel(models, config.model, config.variant, sessionInfo?.model, entries);
|
|
34
|
+
const getResolvedModel = () => modelPreference.get() ?? defaultResolvedModel.model;
|
|
35
|
+
const getModelName = () => formatResolvedModel(getResolvedModel());
|
|
36
|
+
const hideKey = mode === "fresh" ? config.freshKeybind : config.keybind;
|
|
37
|
+
const hiddenCommand = mode === "fresh" ? "/mini-fresh" : "/mini";
|
|
38
|
+
const title = mode === "fresh" ? "mini fresh" : "mini session";
|
|
39
|
+
const previousFocus = context.renderer.currentFocusedRenderable;
|
|
40
|
+
let resolvedAgent;
|
|
41
|
+
let system = "";
|
|
42
|
+
let preamble = "";
|
|
43
|
+
const dialogState = {
|
|
44
|
+
mode,
|
|
45
|
+
entries: [],
|
|
46
|
+
streamingAnswer: "",
|
|
47
|
+
loading: false,
|
|
48
|
+
scrollbarVisible: false,
|
|
49
|
+
spinnerFrame: 0,
|
|
50
|
+
copiedContextTokens: copiedContext.usedTokens,
|
|
51
|
+
copiedContextTotalTokens: copiedContext.totalAvailableTokens,
|
|
52
|
+
lastCompletedMiniInputTokens: undefined,
|
|
53
|
+
modelContextWindow: undefined,
|
|
54
|
+
footerCounter: {},
|
|
55
|
+
inputPlaceholder: undefined,
|
|
56
|
+
thinkingEnabled: thinkingPreference.get(),
|
|
57
|
+
expandedThinkingPartIDs: {},
|
|
58
|
+
notice: undefined,
|
|
59
|
+
errorDetail: undefined
|
|
60
|
+
};
|
|
61
|
+
const unsubscribers = [];
|
|
62
|
+
let tempSessionID;
|
|
63
|
+
let closed = false;
|
|
64
|
+
let hidden = false;
|
|
65
|
+
let continuing = false;
|
|
66
|
+
let renderTimer;
|
|
67
|
+
let scrollTimer;
|
|
68
|
+
let focusTimer;
|
|
69
|
+
let spinnerTimer;
|
|
70
|
+
let overlayInput;
|
|
71
|
+
let overlayScroller;
|
|
72
|
+
let followStreamingToBottom = true;
|
|
73
|
+
let forceScrollToBottom = true;
|
|
74
|
+
let pendingScrollToBottom = false;
|
|
75
|
+
let lastScrollTop = 0;
|
|
76
|
+
let lastScrollHeight = 0;
|
|
77
|
+
let currentTokenMessageID;
|
|
78
|
+
const incrementedTokenMessageIDs = new Set();
|
|
79
|
+
const syncCounterState = () => {
|
|
80
|
+
dialogState.modelContextWindow = resolveModelContextWindow(models, getResolvedModel());
|
|
81
|
+
dialogState.footerCounter = buildFooterCounterState({
|
|
82
|
+
mode: dialogState.mode,
|
|
83
|
+
copiedContextTokens: dialogState.copiedContextTokens,
|
|
84
|
+
copiedContextTotalTokens: dialogState.copiedContextTotalTokens,
|
|
85
|
+
tokenLimit: config.tokenLimit,
|
|
86
|
+
lastCompletedMiniInputTokens: dialogState.lastCompletedMiniInputTokens,
|
|
87
|
+
modelContextWindow: dialogState.modelContextWindow
|
|
88
|
+
});
|
|
89
|
+
dialogState.inputPlaceholder = dialogState.footerCounter.placeholder;
|
|
90
|
+
};
|
|
91
|
+
const clearScrollTimer = () => {
|
|
92
|
+
pendingScrollToBottom = false;
|
|
93
|
+
if (!scrollTimer) return;
|
|
94
|
+
clearTimeout(scrollTimer);
|
|
95
|
+
scrollTimer = undefined;
|
|
96
|
+
};
|
|
97
|
+
const clearFocusTimer = () => {
|
|
98
|
+
if (!focusTimer) return;
|
|
99
|
+
clearTimeout(focusTimer);
|
|
100
|
+
focusTimer = undefined;
|
|
101
|
+
};
|
|
102
|
+
const clearSpinnerTimer = () => {
|
|
103
|
+
if (!spinnerTimer) return;
|
|
104
|
+
clearInterval(spinnerTimer);
|
|
105
|
+
spinnerTimer = undefined;
|
|
106
|
+
};
|
|
107
|
+
const startSpinnerTimer = () => {
|
|
108
|
+
if (spinnerTimer || closed || hidden || !dialogState.loading) return;
|
|
109
|
+
spinnerTimer = setInterval(() => {
|
|
110
|
+
if (closed || hidden || !dialogState.loading) {
|
|
111
|
+
clearSpinnerTimer();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
dialogState.spinnerFrame = (dialogState.spinnerFrame + 1) % 10;
|
|
115
|
+
renderOverlay();
|
|
116
|
+
}, 80);
|
|
117
|
+
};
|
|
118
|
+
const scheduleInputFocus = () => {
|
|
119
|
+
if (closed || hidden) return;
|
|
120
|
+
clearFocusTimer();
|
|
121
|
+
focusTimer = setTimeout(() => {
|
|
122
|
+
focusTimer = undefined;
|
|
123
|
+
if (closed || hidden) return;
|
|
124
|
+
overlayInput?.focus();
|
|
125
|
+
context.renderer.requestRender();
|
|
126
|
+
}, 0);
|
|
127
|
+
};
|
|
128
|
+
const isScrollerAtBottom = () => {
|
|
129
|
+
if (!overlayScroller) return true;
|
|
130
|
+
return overlayScroller.scrollTop >= Math.max(0, overlayScroller.scrollHeight - overlayScroller.viewport.height) - 1;
|
|
131
|
+
};
|
|
132
|
+
const updateScrollSnapshot = () => {
|
|
133
|
+
lastScrollTop = overlayScroller?.scrollTop ?? 0;
|
|
134
|
+
lastScrollHeight = overlayScroller?.scrollHeight ?? 0;
|
|
135
|
+
};
|
|
136
|
+
const scheduleScrollToBottom = () => {
|
|
137
|
+
if (closed || hidden) return;
|
|
138
|
+
clearScrollTimer();
|
|
139
|
+
pendingScrollToBottom = true;
|
|
140
|
+
scrollTimer = setTimeout(() => {
|
|
141
|
+
scrollTimer = undefined;
|
|
142
|
+
if (closed || hidden) {
|
|
143
|
+
pendingScrollToBottom = false;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
overlayScroller?.scrollTo(Number.MAX_SAFE_INTEGER);
|
|
147
|
+
updateScrollSnapshot();
|
|
148
|
+
pendingScrollToBottom = false;
|
|
149
|
+
context.renderer.requestRender();
|
|
150
|
+
}, 0);
|
|
151
|
+
};
|
|
152
|
+
const scrollBy = delta => {
|
|
153
|
+
followStreamingToBottom = false;
|
|
154
|
+
forceScrollToBottom = false;
|
|
155
|
+
pendingScrollToBottom = false;
|
|
156
|
+
clearScrollTimer();
|
|
157
|
+
overlayScroller?.scrollBy(delta);
|
|
158
|
+
updateScrollSnapshot();
|
|
159
|
+
};
|
|
160
|
+
const scrollTo = position => {
|
|
161
|
+
followStreamingToBottom = position === Number.MAX_SAFE_INTEGER;
|
|
162
|
+
forceScrollToBottom = position === Number.MAX_SAFE_INTEGER;
|
|
163
|
+
pendingScrollToBottom = false;
|
|
164
|
+
if (position !== Number.MAX_SAFE_INTEGER) clearScrollTimer();
|
|
165
|
+
overlayScroller?.scrollTo(position);
|
|
166
|
+
updateScrollSnapshot();
|
|
167
|
+
};
|
|
168
|
+
const restorePreviousFocus = () => {
|
|
169
|
+
setTimeout(() => {
|
|
170
|
+
if (previousFocus && !previousFocus.isDestroyed) previousFocus.focus();
|
|
171
|
+
context.renderer.requestRender();
|
|
172
|
+
}, 0);
|
|
173
|
+
};
|
|
174
|
+
const hide = () => {
|
|
175
|
+
if (closed || hidden) return;
|
|
176
|
+
hidden = true;
|
|
177
|
+
if (renderTimer) {
|
|
178
|
+
clearTimeout(renderTimer);
|
|
179
|
+
renderTimer = undefined;
|
|
180
|
+
}
|
|
181
|
+
clearScrollTimer();
|
|
182
|
+
clearFocusTimer();
|
|
183
|
+
clearSpinnerTimer();
|
|
184
|
+
setOverlay(undefined);
|
|
185
|
+
restorePreviousFocus();
|
|
186
|
+
context.ui.toast.show({
|
|
187
|
+
variant: "info",
|
|
188
|
+
message: hideKey ? `mini hidden. Press ${hideKey} to show it.` : `mini hidden. Run ${hiddenCommand} to show it.`,
|
|
189
|
+
duration: 1000
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
const closeFromUser = async () => {
|
|
193
|
+
context.ui.toast.show({
|
|
194
|
+
variant: "info",
|
|
195
|
+
message: "mini session closed.",
|
|
196
|
+
duration: 1000
|
|
197
|
+
});
|
|
198
|
+
await cleanup();
|
|
199
|
+
};
|
|
200
|
+
const cleanup = async () => {
|
|
201
|
+
if (closed) return;
|
|
202
|
+
closed = true;
|
|
203
|
+
if (active.get() === controller) active.set(undefined);
|
|
204
|
+
while (unsubscribers.length > 0) {
|
|
205
|
+
try {
|
|
206
|
+
unsubscribers.pop()?.();
|
|
207
|
+
} catch {}
|
|
208
|
+
}
|
|
209
|
+
if (renderTimer) clearTimeout(renderTimer);
|
|
210
|
+
clearScrollTimer();
|
|
211
|
+
clearFocusTimer();
|
|
212
|
+
clearSpinnerTimer();
|
|
213
|
+
setOverlay(undefined);
|
|
214
|
+
restorePreviousFocus();
|
|
215
|
+
if (!tempSessionID) return;
|
|
216
|
+
const ephemeralSessionID = tempSessionID;
|
|
217
|
+
tempSessionID = undefined;
|
|
218
|
+
try {
|
|
219
|
+
await context.client.session.interrupt({
|
|
220
|
+
sessionID: ephemeralSessionID
|
|
221
|
+
});
|
|
222
|
+
} catch {}
|
|
223
|
+
try {
|
|
224
|
+
await context.client.session.remove({
|
|
225
|
+
sessionID: ephemeralSessionID
|
|
226
|
+
});
|
|
227
|
+
} catch {}
|
|
228
|
+
};
|
|
229
|
+
const continueInMainThread = async () => {
|
|
230
|
+
const transcript = buildMiniSessionTranscript(dialogState);
|
|
231
|
+
if (continuing || dialogState.loading || dialogState.error || !transcript) return;
|
|
232
|
+
continuing = true;
|
|
233
|
+
try {
|
|
234
|
+
await context.client.session.prompt({
|
|
235
|
+
sessionID: originSessionID,
|
|
236
|
+
text: buildContinuePrompt(transcript)
|
|
237
|
+
});
|
|
238
|
+
context.ui.toast.show({
|
|
239
|
+
variant: "success",
|
|
240
|
+
message: "Side answer added to main session."
|
|
241
|
+
});
|
|
242
|
+
await cleanup();
|
|
243
|
+
} catch (cause) {
|
|
244
|
+
context.ui.toast.show({
|
|
245
|
+
variant: "error",
|
|
246
|
+
message: `Failed to continue in main thread: ${getErrorMessage(cause)}`
|
|
247
|
+
});
|
|
248
|
+
} finally {
|
|
249
|
+
continuing = false;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const toggleThinking = () => {
|
|
253
|
+
dialogState.thinkingEnabled = !dialogState.thinkingEnabled;
|
|
254
|
+
thinkingPreference.set(dialogState.thinkingEnabled);
|
|
255
|
+
dialogState.expandedThinkingPartIDs = {};
|
|
256
|
+
renderOverlay();
|
|
257
|
+
};
|
|
258
|
+
const toggleThinkingPart = partID => {
|
|
259
|
+
if (dialogState.expandedThinkingPartIDs[partID]) delete dialogState.expandedThinkingPartIDs[partID];else dialogState.expandedThinkingPartIDs[partID] = true;
|
|
260
|
+
renderOverlay();
|
|
261
|
+
};
|
|
262
|
+
const renderOverlay = (options = {}) => {
|
|
263
|
+
if (closed) return;
|
|
264
|
+
syncCounterState();
|
|
265
|
+
const streamingActive = dialogState.loading || Boolean(dialogState.streamingAnswer);
|
|
266
|
+
const currentScrollTop = overlayScroller?.scrollTop ?? 0;
|
|
267
|
+
const currentScrollHeight = overlayScroller?.scrollHeight ?? 0;
|
|
268
|
+
if (streamingActive && !forceScrollToBottom && !pendingScrollToBottom) {
|
|
269
|
+
if (isScrollerAtBottom()) followStreamingToBottom = true;else if (currentScrollTop < lastScrollTop || currentScrollHeight <= lastScrollHeight) followStreamingToBottom = false;
|
|
270
|
+
}
|
|
271
|
+
const shouldScrollToBottom = forceScrollToBottom || streamingActive && followStreamingToBottom;
|
|
272
|
+
forceScrollToBottom = false;
|
|
273
|
+
updateScrollSnapshot();
|
|
274
|
+
if (renderTimer) {
|
|
275
|
+
clearTimeout(renderTimer);
|
|
276
|
+
renderTimer = undefined;
|
|
277
|
+
}
|
|
278
|
+
if (hidden) return;
|
|
279
|
+
setOverlay({
|
|
280
|
+
context,
|
|
281
|
+
title,
|
|
282
|
+
modelName: getModelName(),
|
|
283
|
+
hideKey,
|
|
284
|
+
toggleThinkingKeybind: config.toggleThinkingKeybind,
|
|
285
|
+
state: dialogState,
|
|
286
|
+
onScroller: scroller => {
|
|
287
|
+
overlayScroller = scroller;
|
|
288
|
+
},
|
|
289
|
+
onInput: input => {
|
|
290
|
+
overlayInput = input;
|
|
291
|
+
},
|
|
292
|
+
onHide: () => hide(),
|
|
293
|
+
onClose: () => void closeFromUser(),
|
|
294
|
+
onContinue: () => void continueInMainThread(),
|
|
295
|
+
onChangeModel: () => openPickerFn(() => renderOverlay({
|
|
296
|
+
focusInput: true
|
|
297
|
+
})),
|
|
298
|
+
onToggleThinking: toggleThinking,
|
|
299
|
+
onToggleThinkingPart: toggleThinkingPart,
|
|
300
|
+
onSubmit: submitPrompt,
|
|
301
|
+
scrollBy,
|
|
302
|
+
scrollTo,
|
|
303
|
+
submit: () => {
|
|
304
|
+
const value = (overlayInput?.value || "").trim();
|
|
305
|
+
if (value && !dialogState.loading && submitPrompt(value)) {
|
|
306
|
+
if (overlayInput) overlayInput.value = "";
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
if (options.focusInput) scheduleInputFocus();
|
|
311
|
+
if (dialogState.loading) startSpinnerTimer();else clearSpinnerTimer();
|
|
312
|
+
if (shouldScrollToBottom) scheduleScrollToBottom();
|
|
313
|
+
};
|
|
314
|
+
const setPromptError = (path, cause) => {
|
|
315
|
+
dialogState.error = getErrorMessage(cause);
|
|
316
|
+
dialogState.errorDetail = buildMiniErrorDetail({
|
|
317
|
+
path,
|
|
318
|
+
sessionID: tempSessionID,
|
|
319
|
+
resolvedModel: getResolvedModel(),
|
|
320
|
+
resolvedAgent
|
|
321
|
+
});
|
|
322
|
+
dialogState.loading = false;
|
|
323
|
+
clearSpinnerTimer();
|
|
324
|
+
};
|
|
325
|
+
const show = () => {
|
|
326
|
+
if (closed) return;
|
|
327
|
+
hidden = false;
|
|
328
|
+
renderOverlay({
|
|
329
|
+
focusInput: true
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
const controller = {
|
|
333
|
+
close: cleanup,
|
|
334
|
+
hide,
|
|
335
|
+
show,
|
|
336
|
+
isVisible: () => !hidden
|
|
337
|
+
};
|
|
338
|
+
const scheduleRenderOverlay = () => {
|
|
339
|
+
if (closed || renderTimer) return;
|
|
340
|
+
renderTimer = setTimeout(() => {
|
|
341
|
+
renderTimer = undefined;
|
|
342
|
+
renderOverlay();
|
|
343
|
+
}, 50);
|
|
344
|
+
};
|
|
345
|
+
active.set(controller);
|
|
346
|
+
renderOverlay({
|
|
347
|
+
focusInput: true
|
|
348
|
+
});
|
|
349
|
+
try {
|
|
350
|
+
resolvedAgent = await resolveRuntimeMiniAgent(context, config);
|
|
351
|
+
} catch (cause) {
|
|
352
|
+
if (closed) return;
|
|
353
|
+
context.ui.toast.show({
|
|
354
|
+
variant: "error",
|
|
355
|
+
message: `Failed to open mini session: ${getErrorMessage(cause)}`
|
|
356
|
+
});
|
|
357
|
+
await cleanup();
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (closed) return;
|
|
361
|
+
preamble = buildMiniPreamble(contextText, resolvedAgent, mode);
|
|
362
|
+
dialogState.notice = formatMiniNotice(defaultResolvedModel.notice, ...resolvedAgent.notices);
|
|
363
|
+
renderOverlay();
|
|
364
|
+
function submitPrompt(value) {
|
|
365
|
+
const prompt = value.trim();
|
|
366
|
+
if (!prompt || closed) return false;
|
|
367
|
+
if (dialogState.loading) {
|
|
368
|
+
context.ui.toast.show({
|
|
369
|
+
variant: "warning",
|
|
370
|
+
message: "Wait for the current response."
|
|
371
|
+
});
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
if (!tempSessionID) {
|
|
375
|
+
context.ui.toast.show({
|
|
376
|
+
variant: "warning",
|
|
377
|
+
message: "mini session is still opening."
|
|
378
|
+
});
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
const promptSessionID = tempSessionID;
|
|
382
|
+
dialogState.error = undefined;
|
|
383
|
+
dialogState.errorDetail = undefined;
|
|
384
|
+
dialogState.loading = true;
|
|
385
|
+
dialogState.spinnerFrame = 0;
|
|
386
|
+
dialogState.streamingAnswer = "";
|
|
387
|
+
followStreamingToBottom = true;
|
|
388
|
+
forceScrollToBottom = true;
|
|
389
|
+
renderOverlay({
|
|
390
|
+
focusInput: true
|
|
391
|
+
});
|
|
392
|
+
void (async () => {
|
|
393
|
+
try {
|
|
394
|
+
const isFirst = !dialogState.lastCompletedMiniInputTokens;
|
|
395
|
+
const text = isFirst ? `${preamble}\n\n---\n\n${prompt}` : prompt;
|
|
396
|
+
await context.client.session.prompt({
|
|
397
|
+
sessionID: promptSessionID,
|
|
398
|
+
text
|
|
399
|
+
});
|
|
400
|
+
} catch (cause) {
|
|
401
|
+
if (closed) return;
|
|
402
|
+
setPromptError("promptAsync throw", cause);
|
|
403
|
+
renderOverlay();
|
|
404
|
+
}
|
|
405
|
+
})();
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
try {
|
|
409
|
+
const resolvedModel = getResolvedModel();
|
|
410
|
+
const created = await context.client.session.create(buildSessionCreatePayload(resolvedAgent, {
|
|
411
|
+
title: "mini session",
|
|
412
|
+
directory: context.location?.directory ?? "",
|
|
413
|
+
model: resolvedModel
|
|
414
|
+
}));
|
|
415
|
+
tempSessionID = created.id;
|
|
416
|
+
const ephemeralSessionID = tempSessionID;
|
|
417
|
+
const refreshSession = () => {
|
|
418
|
+
dialogState.entries = getSessionEntries(context, ephemeralSessionID);
|
|
419
|
+
dialogState.streamingAnswer = "";
|
|
420
|
+
refreshLastCompletedMiniInputTokens();
|
|
421
|
+
};
|
|
422
|
+
const refreshLastCompletedMiniInputTokens = () => {
|
|
423
|
+
const latest = getLastCompletedMiniInputUsage(dialogState.entries);
|
|
424
|
+
if (!latest) return;
|
|
425
|
+
const current = dialogState.lastCompletedMiniInputTokens;
|
|
426
|
+
if (current === undefined || latest.totalTokens > current) {
|
|
427
|
+
dialogState.lastCompletedMiniInputTokens = latest.totalTokens;
|
|
428
|
+
currentTokenMessageID = latest.messageID;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (latest.messageID === currentTokenMessageID) return;
|
|
432
|
+
if (incrementedTokenMessageIDs.has(latest.messageID)) return;
|
|
433
|
+
incrementedTokenMessageIDs.add(latest.messageID);
|
|
434
|
+
dialogState.lastCompletedMiniInputTokens = current + latest.inputTokens;
|
|
435
|
+
currentTokenMessageID = latest.messageID;
|
|
436
|
+
};
|
|
437
|
+
if (closed) {
|
|
438
|
+
try {
|
|
439
|
+
await context.client.session.remove({
|
|
440
|
+
sessionID: ephemeralSessionID
|
|
441
|
+
});
|
|
442
|
+
} catch {}
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
unsubscribers.push(context.data.on("session.idle", event => {
|
|
446
|
+
if (event.data.sessionID !== tempSessionID) return;
|
|
447
|
+
refreshSession();
|
|
448
|
+
if (!extractAssistantText(dialogState.entries)) dialogState.streamingAnswer = "No response generated.";
|
|
449
|
+
dialogState.loading = false;
|
|
450
|
+
clearSpinnerTimer();
|
|
451
|
+
renderOverlay();
|
|
452
|
+
}));
|
|
453
|
+
unsubscribers.push(context.data.on("session.text.delta", event => {
|
|
454
|
+
if (event.data.sessionID !== tempSessionID) return;
|
|
455
|
+
dialogState.streamingAnswer += event.data.delta;
|
|
456
|
+
scheduleRenderOverlay();
|
|
457
|
+
}));
|
|
458
|
+
unsubscribers.push(context.data.on("session.step.ended", event => {
|
|
459
|
+
if (event.data.sessionID !== tempSessionID) return;
|
|
460
|
+
refreshSession();
|
|
461
|
+
renderOverlay();
|
|
462
|
+
}));
|
|
463
|
+
unsubscribers.push(context.data.on("session.execution.failed", event => {
|
|
464
|
+
if (event.data.sessionID !== tempSessionID) return;
|
|
465
|
+
setPromptError("session.execution.failed", event.data.error.message);
|
|
466
|
+
renderOverlay();
|
|
467
|
+
}));
|
|
468
|
+
} catch (cause) {
|
|
469
|
+
if (closed) return;
|
|
470
|
+
setPromptError("session.create throw", cause);
|
|
471
|
+
renderOverlay();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
export function openModelPicker(context, config, sessionID, modelPreference, onAfterSelect) {
|
|
475
|
+
const sessionInfo = context.data.session.get(sessionID);
|
|
476
|
+
const models = context.data.location.model.list(sessionInfo?.location);
|
|
477
|
+
const providers = context.data.location.provider.list(sessionInfo?.location);
|
|
478
|
+
const {
|
|
479
|
+
model: defaultModel,
|
|
480
|
+
source: defaultSource
|
|
481
|
+
} = resolveDefaultModel(models, config.model, config.variant, sessionInfo?.model, getSessionEntries(context, sessionID));
|
|
482
|
+
const options = buildModelOptions(models, providers, defaultModel, defaultSource);
|
|
483
|
+
const sourceLabel = {
|
|
484
|
+
config: "config",
|
|
485
|
+
session: "main session",
|
|
486
|
+
default: "default"
|
|
487
|
+
};
|
|
488
|
+
void (async () => {
|
|
489
|
+
const result = await context.ui.dialog.select({
|
|
490
|
+
title: "mini model",
|
|
491
|
+
placeholder: "Select model for future mini-session questions",
|
|
492
|
+
options: options.map(o => ({
|
|
493
|
+
title: o.title,
|
|
494
|
+
value: o.value,
|
|
495
|
+
description: o.description,
|
|
496
|
+
category: o.category
|
|
497
|
+
}))
|
|
498
|
+
});
|
|
499
|
+
if (!result) return;
|
|
500
|
+
if (result.type === "default") {
|
|
501
|
+
modelPreference.set(undefined);
|
|
502
|
+
context.ui.toast.show({
|
|
503
|
+
variant: "success",
|
|
504
|
+
message: "mini model reset to default."
|
|
505
|
+
});
|
|
506
|
+
} else {
|
|
507
|
+
modelPreference.set(result.model);
|
|
508
|
+
context.ui.toast.show({
|
|
509
|
+
variant: "success",
|
|
510
|
+
message: `mini model set to ${formatResolvedModel(result.model)}.`
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
onAfterSelect?.();
|
|
514
|
+
})();
|
|
515
|
+
}
|
|
516
|
+
function buildModelOptions(models, providers, defaultModel, defaultSource) {
|
|
517
|
+
const sourceLabel = {
|
|
518
|
+
config: "config",
|
|
519
|
+
session: "main session",
|
|
520
|
+
default: "default"
|
|
521
|
+
};
|
|
522
|
+
const providerName = id => providers?.find(p => p.id === id)?.name ?? id;
|
|
523
|
+
const defaultModelName = defaultModel.providerID && defaultModel.id ? models?.find(m => m.providerID === defaultModel.providerID && m.id === defaultModel.id)?.name ?? defaultModel.id : "default";
|
|
524
|
+
const options = [{
|
|
525
|
+
title: defaultModelName + (defaultModel.variant ? ` (${defaultModel.variant})` : ""),
|
|
526
|
+
value: {
|
|
527
|
+
type: "default"
|
|
528
|
+
},
|
|
529
|
+
description: formatResolvedModel(defaultModel),
|
|
530
|
+
category: `Default [${sourceLabel[defaultSource]}]`
|
|
531
|
+
}];
|
|
532
|
+
if (!models) return options;
|
|
533
|
+
const byProvider = new Map();
|
|
534
|
+
for (const m of models) {
|
|
535
|
+
const arr = byProvider.get(m.providerID) ?? [];
|
|
536
|
+
arr.push(m);
|
|
537
|
+
byProvider.set(m.providerID, arr);
|
|
538
|
+
}
|
|
539
|
+
for (const [providerID, providerModels] of [...byProvider.entries()].sort((a, b) => providerName(a[0]).localeCompare(providerName(b[0])))) {
|
|
540
|
+
for (const m of providerModels.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
541
|
+
const value = {
|
|
542
|
+
providerID: m.providerID,
|
|
543
|
+
id: m.id
|
|
544
|
+
};
|
|
545
|
+
options.push({
|
|
546
|
+
title: m.name || m.id,
|
|
547
|
+
value: {
|
|
548
|
+
type: "model",
|
|
549
|
+
model: value
|
|
550
|
+
},
|
|
551
|
+
description: `${providerID}/${m.id}`,
|
|
552
|
+
category: providerName(providerID)
|
|
553
|
+
});
|
|
554
|
+
for (const variant of [...(m.variants ?? [])].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
555
|
+
options.push({
|
|
556
|
+
title: `${m.name || m.id} (${variant.id})`,
|
|
557
|
+
value: {
|
|
558
|
+
type: "model",
|
|
559
|
+
model: {
|
|
560
|
+
...value,
|
|
561
|
+
variant: variant.id
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
description: `${providerID}/${m.id}`,
|
|
565
|
+
category: providerName(providerID)
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return options;
|
|
571
|
+
}
|
|
572
|
+
export function extractAssistantText(entries) {
|
|
573
|
+
const chunks = [];
|
|
574
|
+
for (const entry of entries) {
|
|
575
|
+
if (entry.type !== "assistant") continue;
|
|
576
|
+
for (const part of entry.content) {
|
|
577
|
+
if (part.type === "text" && part.text.trim()) chunks.push(part.text);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return chunks.join("\n\n").trim();
|
|
581
|
+
}
|
|
582
|
+
function buildMiniSessionTranscript(state) {
|
|
583
|
+
const lines = [];
|
|
584
|
+
for (const entry of state.entries) {
|
|
585
|
+
const chunks = [];
|
|
586
|
+
if (entry.type === "user" && entry.text.trim()) chunks.push(entry.text.trim());
|
|
587
|
+
if (entry.type === "assistant") {
|
|
588
|
+
for (const part of entry.content) {
|
|
589
|
+
if (part.type === "text" && part.text.trim()) chunks.push(part.text.trim());
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (chunks.length > 0) {
|
|
593
|
+
const role = entry.type === "assistant" ? "assistant" : entry.type === "user" ? "user" : "system";
|
|
594
|
+
lines.push(`${role}:\n${chunks.join("\n\n")}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (state.streamingAnswer.trim()) lines.push(`assistant:\n${state.streamingAnswer.trim()}`);
|
|
598
|
+
return lines.join("\n\n").trim();
|
|
599
|
+
}
|
|
600
|
+
function buildContinuePrompt(transcript) {
|
|
601
|
+
return ["[Context from a mini session]", transcript, "---\n"].join("\n\n");
|
|
602
|
+
}
|
|
603
|
+
function getLastCompletedMiniInputUsage(entries) {
|
|
604
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
605
|
+
const entry = entries[i];
|
|
606
|
+
if (entry.type !== "assistant") continue;
|
|
607
|
+
if (!entry.time.completed) continue;
|
|
608
|
+
if (entry.tokens) {
|
|
609
|
+
return {
|
|
610
|
+
messageID: entry.id,
|
|
611
|
+
inputTokens: entry.tokens.input + (entry.tokens.cache?.read ?? 0) + (entry.tokens.cache?.write ?? 0),
|
|
612
|
+
totalTokens: entry.tokens.input + entry.tokens.output + entry.tokens.reasoning + (entry.tokens.cache?.read ?? 0) + (entry.tokens.cache?.write ?? 0)
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
package/dist/theme.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function buildMiniTheme(theme) {
|
|
2
|
+
const feedback = theme.text.feedback;
|
|
3
|
+
const action = theme.text.action;
|
|
4
|
+
return {
|
|
5
|
+
text: theme.text.default,
|
|
6
|
+
textMuted: theme.text.subdued,
|
|
7
|
+
primary: action.primary.default,
|
|
8
|
+
secondary: action.secondary.default,
|
|
9
|
+
error: feedback.error.default,
|
|
10
|
+
warning: feedback.warning.default,
|
|
11
|
+
info: feedback.info.default,
|
|
12
|
+
backgroundPanel: theme.background.surface.overlay,
|
|
13
|
+
borderSubtle: theme.background.surface.offset,
|
|
14
|
+
border: theme.border.default,
|
|
15
|
+
markdownHeading: theme.markdown.heading,
|
|
16
|
+
markdownStrong: theme.markdown.strong,
|
|
17
|
+
markdownEmph: theme.markdown.emphasis,
|
|
18
|
+
markdownLink: theme.markdown.link,
|
|
19
|
+
markdownLinkText: theme.markdown.linkText,
|
|
20
|
+
markdownCode: theme.markdown.code,
|
|
21
|
+
markdownCodeBlock: theme.markdown.codeBlock,
|
|
22
|
+
markdownBlockQuote: theme.markdown.blockQuote,
|
|
23
|
+
markdownText: theme.markdown.text,
|
|
24
|
+
syntaxComment: theme.syntax.comment,
|
|
25
|
+
syntaxKeyword: theme.syntax.keyword,
|
|
26
|
+
syntaxFunction: theme.syntax.function,
|
|
27
|
+
syntaxVariable: theme.syntax.variable,
|
|
28
|
+
syntaxString: theme.syntax.string,
|
|
29
|
+
syntaxNumber: theme.syntax.number,
|
|
30
|
+
syntaxType: theme.syntax.type,
|
|
31
|
+
syntaxOperator: theme.syntax.operator,
|
|
32
|
+
syntaxPunctuation: theme.syntax.punctuation
|
|
33
|
+
};
|
|
34
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@npv12/opencode-mini-session",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"opencode": "0.0.0-beta-17963"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
"./tui": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/npv12/opencode-mini-session"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "node scripts/build.mjs",
|
|
20
|
+
"prepack": "npm run build",
|
|
21
|
+
"release": "node scripts/release.mjs",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:package": "node scripts/verify-packaged-tui.mjs",
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@opencode-ai/client": "0.0.0-beta-17963",
|
|
28
|
+
"@opencode-ai/plugin": "0.0.0-beta-17963",
|
|
29
|
+
"@opencode-ai/theme": "0.0.0-beta-17963",
|
|
30
|
+
"@opentui/core": "0.5.7",
|
|
31
|
+
"@opentui/solid": "0.5.7",
|
|
32
|
+
"solid-js": "1.9.14"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@babel/core": "^7.28.0",
|
|
36
|
+
"@babel/preset-typescript": "^7.27.1",
|
|
37
|
+
"@types/node": "^25.9.1",
|
|
38
|
+
"babel-preset-solid": "^1.9.12",
|
|
39
|
+
"typescript": "^5.9.3",
|
|
40
|
+
"vitest": "^4.1.7"
|
|
41
|
+
}
|
|
42
|
+
}
|