@oh-my-pi/pi-coding-agent 16.4.4 → 16.4.5
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/CHANGELOG.md +33 -0
- package/dist/cli.js +3563 -3540
- package/dist/types/async/job-manager.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +18 -8
- package/dist/types/config/settings.d.ts +3 -2
- package/dist/types/discovery/helpers.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +36 -0
- package/dist/types/irc/bus.d.ts +4 -0
- package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +1 -0
- package/dist/types/modes/components/ask-dialog.d.ts +27 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/model-browser.d.ts +97 -0
- package/dist/types/modes/components/model-hub.d.ts +51 -0
- package/dist/types/modes/components/pause-screen.d.ts +43 -0
- package/dist/types/modes/components/session-selector.d.ts +13 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/shared.d.ts +1 -1
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +9 -0
- package/dist/types/task/executor.d.ts +26 -13
- package/dist/types/task/index.d.ts +12 -11
- package/dist/types/task/label.d.ts +4 -0
- package/dist/types/task/repair-args.d.ts +8 -8
- package/dist/types/task/types.d.ts +31 -56
- package/dist/types/tools/ask.d.ts +12 -0
- package/dist/types/tools/conflict-detect.d.ts +17 -1
- package/dist/types/tools/job.d.ts +16 -0
- package/package.json +12 -12
- package/src/async/job-manager.ts +9 -0
- package/src/commit/agentic/tools/analyze-file.ts +2 -3
- package/src/config/settings-schema.ts +17 -6
- package/src/config/settings.ts +13 -4
- package/src/discovery/helpers.ts +3 -4
- package/src/extensibility/custom-tools/loader.ts +70 -37
- package/src/extensibility/extensions/types.ts +46 -0
- package/src/irc/bus.ts +61 -20
- package/src/modes/components/__tests__/pause-screen.test.ts +143 -0
- package/src/modes/components/advisor-config.ts +30 -22
- package/src/modes/components/ask-dialog.ts +888 -0
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/model-browser.ts +688 -0
- package/src/modes/components/model-hub.ts +1655 -0
- package/src/modes/components/pause-screen.ts +208 -0
- package/src/modes/components/session-selector.ts +299 -42
- package/src/modes/components/tool-execution.ts +2 -0
- package/src/modes/controllers/event-controller.ts +8 -2
- package/src/modes/controllers/extension-ui-controller.ts +252 -5
- package/src/modes/controllers/selector-controller.ts +143 -87
- package/src/modes/controllers/tan-command-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +3 -0
- package/src/modes/shared.ts +1 -1
- package/src/modes/theme/theme.ts +3 -3
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +31 -4
- package/src/prompts/agents/scout.md +0 -1
- package/src/prompts/agents/task.md +1 -1
- package/src/prompts/system/subagent-system-prompt.md +1 -5
- package/src/prompts/system/subagent-yield-reminder.md +10 -0
- package/src/prompts/system/task-label.md +23 -0
- package/src/prompts/tools/job.md +1 -1
- package/src/prompts/tools/task-summary.md +3 -0
- package/src/prompts/tools/task.md +17 -18
- package/src/session/agent-session.ts +2 -1
- package/src/session/session-context.test.ts +73 -0
- package/src/session/session-context.ts +43 -26
- package/src/slash-commands/builtin-registry.ts +9 -0
- package/src/task/agents.ts +2 -0
- package/src/task/executor.ts +159 -46
- package/src/task/index.ts +377 -239
- package/src/task/label.ts +38 -0
- package/src/task/render.ts +74 -22
- package/src/task/repair-args.ts +20 -31
- package/src/task/spawn-policy.test.ts +4 -4
- package/src/task/types.ts +46 -66
- package/src/tools/ask.ts +233 -40
- package/src/tools/conflict-detect.ts +102 -5
- package/src/tools/index.ts +1 -0
- package/src/tools/irc.ts +20 -11
- package/src/tools/job.ts +158 -18
- package/src/tools/write.ts +70 -6
- package/src/vibe/runtime.ts +1 -1
- package/src/web/search/providers/browser-headers.ts +30 -13
- package/dist/types/modes/components/model-selector.d.ts +0 -37
- package/dist/types/tools/bash-command-fixup.d.ts +0 -3
- package/src/modes/components/model-selector.ts +0 -1291
- package/src/tools/bash-command-fixup.ts +0 -4
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fullscreen `/pause` screen.
|
|
3
|
+
*
|
|
4
|
+
* `/pause` engages the process-global {@link agentPauseGate}, freezing every
|
|
5
|
+
* agent loop in the process (main agent, in-process subagents, advisor) at its
|
|
6
|
+
* next safe boundary — nothing is aborted, so a later resume continues exactly
|
|
7
|
+
* where each loop parked. While engaged, this component owns the alternate
|
|
8
|
+
* screen (the `runStartupSplash` idiom) and paints a large pause glyph with a
|
|
9
|
+
* live hold timer; esc / enter / space / ctrl+c releases the gate.
|
|
10
|
+
*
|
|
11
|
+
* Use case: freeze a busy session, hand-edit the repo, resume, then explain
|
|
12
|
+
* the change via a normal steering message.
|
|
13
|
+
*/
|
|
14
|
+
import { agentPauseGate } from "@oh-my-pi/pi-agent-core";
|
|
15
|
+
import {
|
|
16
|
+
type Component,
|
|
17
|
+
matchesKey,
|
|
18
|
+
type OverlayFocusOwner,
|
|
19
|
+
type OverlayHandle,
|
|
20
|
+
type OverlayOptions,
|
|
21
|
+
visibleWidth,
|
|
22
|
+
} from "@oh-my-pi/pi-tui";
|
|
23
|
+
import { formatDuration } from "../../slash-commands/helpers/format";
|
|
24
|
+
import { theme } from "../theme/theme";
|
|
25
|
+
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
|
|
29
|
+
* can exercise the full engage → hold → release lifecycle without a real TUI.
|
|
30
|
+
*/
|
|
31
|
+
export interface PauseScreenHost {
|
|
32
|
+
ui: {
|
|
33
|
+
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
|
|
34
|
+
setFocus(component: Component): void;
|
|
35
|
+
requestRender(): void;
|
|
36
|
+
readonly terminal: { readonly rows: number };
|
|
37
|
+
};
|
|
38
|
+
showStatus(message: string, options?: { dim?: boolean }): void;
|
|
39
|
+
readonly sessionName?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Refresh cadence for the live "paused for" clock. */
|
|
43
|
+
const TICK_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
/** Pause-bar glyph geometry (rows × columns of full blocks per bar). */
|
|
46
|
+
const BAR_ROWS = 7;
|
|
47
|
+
const BAR_WIDTH = 5;
|
|
48
|
+
const BAR_GAP = 4;
|
|
49
|
+
|
|
50
|
+
/** Below either bound the full scene cannot breathe; drop to the compact card. */
|
|
51
|
+
const MIN_FULL_WIDTH = 64;
|
|
52
|
+
const MIN_FULL_HEIGHT = 18;
|
|
53
|
+
|
|
54
|
+
const TITLE = "P A U S E D";
|
|
55
|
+
const BODY_LINES = [
|
|
56
|
+
"Main agent, subagents, and advisor hold at their next step.",
|
|
57
|
+
"In-flight calls finish; nothing new starts until you resume.",
|
|
58
|
+
] as const;
|
|
59
|
+
const RESUME_HINT = "esc · enter · space — resume";
|
|
60
|
+
|
|
61
|
+
function centerLine(line: string, width: number): string {
|
|
62
|
+
const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
|
|
63
|
+
return pad > 0 ? " ".repeat(pad) + line : line;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Live hold clock, seconds-precise: `0:07`, `12:34`, `1:02:03`. */
|
|
67
|
+
function formatClock(ms: number): string {
|
|
68
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
69
|
+
const seconds = totalSeconds % 60;
|
|
70
|
+
const minutes = Math.floor(totalSeconds / 60) % 60;
|
|
71
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
72
|
+
if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
73
|
+
return `${minutes}:${String(seconds).padStart(2, "0")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Paint the pause scene as exactly `height` rows, vertically centered.
|
|
78
|
+
* Exported for tests.
|
|
79
|
+
*/
|
|
80
|
+
export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
|
|
81
|
+
const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
|
|
82
|
+
const content: string[] = [];
|
|
83
|
+
|
|
84
|
+
if (compact) {
|
|
85
|
+
if (sessionName) {
|
|
86
|
+
content.push(centerLine(theme.bold(sessionName), width));
|
|
87
|
+
content.push("");
|
|
88
|
+
}
|
|
89
|
+
content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width));
|
|
90
|
+
content.push("");
|
|
91
|
+
content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width));
|
|
92
|
+
content.push(centerLine(theme.fg("dim", "esc to resume"), width));
|
|
93
|
+
} else {
|
|
94
|
+
if (sessionName) {
|
|
95
|
+
content.push(centerLine(theme.bold(sessionName), width));
|
|
96
|
+
content.push("");
|
|
97
|
+
content.push("");
|
|
98
|
+
}
|
|
99
|
+
const bar = "█".repeat(BAR_WIDTH);
|
|
100
|
+
const glyphRow = `${bar}${" ".repeat(BAR_GAP)}${bar}`;
|
|
101
|
+
for (let i = 0; i < BAR_ROWS; i++) {
|
|
102
|
+
content.push(centerLine(theme.fg("accent", glyphRow), width));
|
|
103
|
+
}
|
|
104
|
+
content.push("");
|
|
105
|
+
content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), width));
|
|
106
|
+
content.push("");
|
|
107
|
+
for (const line of BODY_LINES) {
|
|
108
|
+
content.push(centerLine(theme.fg("muted", line), width));
|
|
109
|
+
}
|
|
110
|
+
content.push("");
|
|
111
|
+
content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width));
|
|
112
|
+
content.push("");
|
|
113
|
+
content.push(centerLine(theme.fg("dim", RESUME_HINT), width));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const topPad = Math.max(0, Math.floor((height - content.length) / 2));
|
|
117
|
+
const lines: string[] = new Array(topPad).fill("");
|
|
118
|
+
lines.push(...content);
|
|
119
|
+
while (lines.length < height) lines.push("");
|
|
120
|
+
return lines.slice(0, Math.max(1, height));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Fullscreen overlay component; resolves {@link run} when a resume key lands. */
|
|
124
|
+
export class PauseScreenComponent implements Component, OverlayFocusOwner {
|
|
125
|
+
#timer: NodeJS.Timeout | undefined;
|
|
126
|
+
#done = Promise.withResolvers<void>();
|
|
127
|
+
#disposed = false;
|
|
128
|
+
#startedAt = Date.now();
|
|
129
|
+
|
|
130
|
+
constructor(readonly host: PauseScreenHost) {}
|
|
131
|
+
|
|
132
|
+
/** Start the clock; resolves once the user asks to resume. */
|
|
133
|
+
run(): Promise<void> {
|
|
134
|
+
this.#startedAt = agentPauseGate.pausedAt ?? Date.now();
|
|
135
|
+
this.#timer ??= setInterval(() => {
|
|
136
|
+
if (!this.#disposed) this.host.ui.requestRender();
|
|
137
|
+
}, TICK_MS);
|
|
138
|
+
this.host.ui.requestRender();
|
|
139
|
+
return this.#done.promise;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
dispose(): void {
|
|
143
|
+
this.#disposed = true;
|
|
144
|
+
if (this.#timer) {
|
|
145
|
+
clearInterval(this.#timer);
|
|
146
|
+
this.#timer = undefined;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
ownsOverlayFocusTarget(component: Component): boolean {
|
|
151
|
+
return component === this;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
handleInput(data: string): void {
|
|
155
|
+
// Every dismissal path resumes — including ctrl+c, which must never
|
|
156
|
+
// double as "abort agents" while the whole point of the screen is that
|
|
157
|
+
// nothing gets lost.
|
|
158
|
+
if (
|
|
159
|
+
matchesAppInterrupt(data) ||
|
|
160
|
+
matchesKey(data, "enter") ||
|
|
161
|
+
matchesKey(data, "return") ||
|
|
162
|
+
matchesKey(data, "space") ||
|
|
163
|
+
matchesKey(data, "ctrl+c")
|
|
164
|
+
) {
|
|
165
|
+
if (!this.#disposed) this.#done.resolve();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
render(width: number): readonly string[] {
|
|
170
|
+
const elapsed = Date.now() - this.#startedAt;
|
|
171
|
+
return renderPauseScreen(
|
|
172
|
+
Math.max(1, width),
|
|
173
|
+
Math.max(1, this.host.ui.terminal.rows),
|
|
174
|
+
elapsed,
|
|
175
|
+
this.host.sessionName,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Engage the global pause gate and hold the fullscreen pause screen until the
|
|
182
|
+
* user resumes. No-op when the gate is already engaged. Always releases the
|
|
183
|
+
* gate on the way out (including teardown throws) — a leaked pause would
|
|
184
|
+
* freeze every agent in the process with no UI left to release it.
|
|
185
|
+
*/
|
|
186
|
+
export async function runPauseScreen(host: PauseScreenHost): Promise<void> {
|
|
187
|
+
if (!agentPauseGate.pause()) return;
|
|
188
|
+
const component = new PauseScreenComponent(host);
|
|
189
|
+
const overlay = host.ui.showOverlay(component, {
|
|
190
|
+
width: "100%",
|
|
191
|
+
maxHeight: "100%",
|
|
192
|
+
anchor: "top-left",
|
|
193
|
+
margin: 0,
|
|
194
|
+
fullscreen: true,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
host.ui.setFocus(component);
|
|
198
|
+
await component.run();
|
|
199
|
+
} finally {
|
|
200
|
+
component.dispose();
|
|
201
|
+
host.ui.setFocus(component);
|
|
202
|
+
overlay.hide();
|
|
203
|
+
const heldMs = agentPauseGate.resume();
|
|
204
|
+
if (heldMs !== undefined) {
|
|
205
|
+
host.showStatus(`Resumed after ${formatDuration(heldMs)} — agents are running again.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Component,
|
|
3
3
|
Container,
|
|
4
|
-
|
|
4
|
+
FuzzyText,
|
|
5
5
|
Input,
|
|
6
6
|
matchesKey,
|
|
7
7
|
padding,
|
|
@@ -59,6 +59,33 @@ function sessionSearchText(session: SessionInfo): string {
|
|
|
59
59
|
return parts.filter(Boolean).join(" ");
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Lowercased per-session search haystack, built once and cached on the
|
|
64
|
+
* {@link SessionInfo} itself (so it dies with the listing that produced it).
|
|
65
|
+
* Rebuilding it per keystroke — a ~4KB string join plus `toLowerCase` per
|
|
66
|
+
* session — was one of the costs that made resume search visibly lag.
|
|
67
|
+
*
|
|
68
|
+
* Only the string is cached. A prebuilt fuzzy index (~60KB per 4KB session)
|
|
69
|
+
* would cost hundreds of MB on multi-thousand-session listings, so fuzzy
|
|
70
|
+
* indexes are built transiently per scan visit instead (see
|
|
71
|
+
* {@link scoreFuzzySession} callers).
|
|
72
|
+
*/
|
|
73
|
+
const kSearchTextLower = Symbol("session.searchTextLower");
|
|
74
|
+
|
|
75
|
+
interface SearchableSessionInfo extends SessionInfo {
|
|
76
|
+
[kSearchTextLower]?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sessionTextLower(session: SessionInfo): string {
|
|
80
|
+
const tagged = session as SearchableSessionInfo;
|
|
81
|
+
let textLower = tagged[kSearchTextLower];
|
|
82
|
+
if (textLower === undefined) {
|
|
83
|
+
textLower = sessionSearchText(session).toLowerCase();
|
|
84
|
+
tagged[kSearchTextLower] = textLower;
|
|
85
|
+
}
|
|
86
|
+
return textLower;
|
|
87
|
+
}
|
|
88
|
+
|
|
62
89
|
function tokenizeSessionQuery(query: string): string[] {
|
|
63
90
|
const trimmed = query.trim().toLowerCase();
|
|
64
91
|
return trimmed ? trimmed.split(/\s+/) : [];
|
|
@@ -70,6 +97,59 @@ function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
|
|
|
70
97
|
|
|
71
98
|
const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
|
|
72
99
|
|
|
100
|
+
/** One ranked search hit; `index` is the session's position in the unfiltered list (recency order). */
|
|
101
|
+
interface RankedSessionMatch {
|
|
102
|
+
session: SessionInfo;
|
|
103
|
+
score: number;
|
|
104
|
+
index: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* True when every query token appears verbatim in the haystack. Literal
|
|
109
|
+
* matches rank purely by recency, so they skip fuzzy scoring entirely — a pure
|
|
110
|
+
* fast path, not a semantic change: a contiguous substring of the lowercased
|
|
111
|
+
* text always lies within one normalized word per query sub-token, so every
|
|
112
|
+
* literal token also fuzzy-matches.
|
|
113
|
+
*/
|
|
114
|
+
function isLiteralMatch(textLower: string, tokens: string[]): boolean {
|
|
115
|
+
for (const token of tokens) {
|
|
116
|
+
if (!textLower.includes(token)) return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fuzzy-score one non-literal session against every query token. Returns
|
|
123
|
+
* undefined when a token fails to match or the weakest token is pure-fuzzy
|
|
124
|
+
* noise. The caller builds `fuzzy` once per session visit so multi-token
|
|
125
|
+
* queries share a single index.
|
|
126
|
+
*/
|
|
127
|
+
function scoreFuzzySession(
|
|
128
|
+
session: SessionInfo,
|
|
129
|
+
index: number,
|
|
130
|
+
tokens: string[],
|
|
131
|
+
fuzzy: FuzzyText,
|
|
132
|
+
): RankedSessionMatch | undefined {
|
|
133
|
+
let score = 0;
|
|
134
|
+
let worstTokenScore = Number.NEGATIVE_INFINITY;
|
|
135
|
+
for (const token of tokens) {
|
|
136
|
+
const match = fuzzy.match(token);
|
|
137
|
+
if (!match.matches) return undefined;
|
|
138
|
+
score += match.score;
|
|
139
|
+
worstTokenScore = Math.max(worstTokenScore, match.score);
|
|
140
|
+
}
|
|
141
|
+
if (worstTokenScore >= MIN_PURE_FUZZY_TOKEN_SCORE) return undefined;
|
|
142
|
+
return { session, score, index };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function compareLiteralRank(a: RankedSessionMatch, b: RankedSessionMatch): number {
|
|
146
|
+
return compareSessionRecency(a.session, b.session) || a.index - b.index;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function compareFuzzyRank(a: RankedSessionMatch, b: RankedSessionMatch): number {
|
|
150
|
+
return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index;
|
|
151
|
+
}
|
|
152
|
+
|
|
73
153
|
/**
|
|
74
154
|
* Filter and rank session picker search results.
|
|
75
155
|
*
|
|
@@ -77,44 +157,33 @@ const MIN_PURE_FUZZY_TOKEN_SCORE = -20;
|
|
|
77
157
|
* as a literal substring, newer sessions should beat a slightly better fuzzy
|
|
78
158
|
* position match. Pure fuzzy/acronym matches still sort by fuzzy score after
|
|
79
159
|
* literal matches, but weak pure fuzzy tokens are dropped as noise.
|
|
160
|
+
*
|
|
161
|
+
* This is the synchronous reference implementation; {@link SessionList} runs
|
|
162
|
+
* the same primitives incrementally so huge listings never block a keystroke.
|
|
80
163
|
*/
|
|
81
164
|
export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
|
|
82
165
|
const tokens = tokenizeSessionQuery(query);
|
|
83
166
|
if (tokens.length === 0) return allSessions;
|
|
84
167
|
|
|
85
|
-
const
|
|
168
|
+
const literal: RankedSessionMatch[] = [];
|
|
169
|
+
const fuzzyMatches: RankedSessionMatch[] = [];
|
|
86
170
|
for (let index = 0; index < allSessions.length; index++) {
|
|
87
171
|
const session = allSessions[index]!;
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
let literal = true;
|
|
93
|
-
let matches = true;
|
|
94
|
-
|
|
95
|
-
for (const token of tokens) {
|
|
96
|
-
const match = fuzzyMatch(token, textLower);
|
|
97
|
-
if (!match.matches) {
|
|
98
|
-
matches = false;
|
|
99
|
-
break;
|
|
100
|
-
}
|
|
101
|
-
score += match.score;
|
|
102
|
-
worstTokenScore = Math.max(worstTokenScore, match.score);
|
|
103
|
-
if (!textLower.includes(token)) literal = false;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (matches && (literal || worstTokenScore < MIN_PURE_FUZZY_TOKEN_SCORE)) {
|
|
107
|
-
results.push({ session, score, literal, index });
|
|
172
|
+
const textLower = sessionTextLower(session);
|
|
173
|
+
if (isLiteralMatch(textLower, tokens)) {
|
|
174
|
+
literal.push({ session, score: 0, index });
|
|
175
|
+
continue;
|
|
108
176
|
}
|
|
177
|
+
const match = scoreFuzzySession(session, index, tokens, new FuzzyText(textLower));
|
|
178
|
+
if (match) fuzzyMatches.push(match);
|
|
109
179
|
}
|
|
110
180
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
return results.map(result => result.session);
|
|
181
|
+
literal.sort(compareLiteralRank);
|
|
182
|
+
fuzzyMatches.sort(compareFuzzyRank);
|
|
183
|
+
const out: SessionInfo[] = [];
|
|
184
|
+
for (const match of literal) out.push(match.session);
|
|
185
|
+
for (const match of fuzzyMatches) out.push(match.session);
|
|
186
|
+
return out;
|
|
118
187
|
}
|
|
119
188
|
|
|
120
189
|
/**
|
|
@@ -156,6 +225,37 @@ export function mergeSessionRanking(
|
|
|
156
225
|
return [...historyMatches, ...metadataOnly];
|
|
157
226
|
}
|
|
158
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Delay before the prompt-history DB is consulted for the current query.
|
|
230
|
+
* History matching hits SQLite synchronously (an FTS lookup plus a LIKE scan
|
|
231
|
+
* over every stored prompt — tens to hundreds of ms on a year-old database),
|
|
232
|
+
* so it must never run per keystroke: fuzzy results render immediately and
|
|
233
|
+
* the history merge lands once typing pauses.
|
|
234
|
+
*/
|
|
235
|
+
const HISTORY_MERGE_DEBOUNCE_MS = 150;
|
|
236
|
+
/**
|
|
237
|
+
* Minimum query length for history augmentation. A single character matches
|
|
238
|
+
* essentially every stored prompt — the most expensive FTS prefix to expand —
|
|
239
|
+
* and only reorders the recency-ranked list by noise.
|
|
240
|
+
*/
|
|
241
|
+
const HISTORY_MERGE_MIN_QUERY = 2;
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Sessions fuzzy-scored synchronously inside the keystroke itself. Small
|
|
245
|
+
* listings finish within it, keeping the complete-in-one-frame behavior;
|
|
246
|
+
* anything left spills into async chunks. A fuzzy visit costs ~100µs (index
|
|
247
|
+
* build over the ≤4KB per-session corpus dominates), so 100 visits ≈ 10ms —
|
|
248
|
+
* about one frame. Counts rather than a deadline keep chunk boundaries
|
|
249
|
+
* deterministic (and testable under fake timers).
|
|
250
|
+
*/
|
|
251
|
+
const FUZZY_SCAN_INLINE_COUNT = 100;
|
|
252
|
+
/**
|
|
253
|
+
* Sessions fuzzy-scored per async chunk (~15ms). Each chunk yields back to
|
|
254
|
+
* the event loop so the next keystroke is never blocked behind a long scan; a
|
|
255
|
+
* new query bumps the scan generation and orphans pending chunks.
|
|
256
|
+
*/
|
|
257
|
+
const FUZZY_SCAN_CHUNK_COUNT = 150;
|
|
258
|
+
|
|
159
259
|
/**
|
|
160
260
|
* Custom session list component with multi-line items and search
|
|
161
261
|
*/
|
|
@@ -182,6 +282,29 @@ class SessionList implements Component {
|
|
|
182
282
|
#allSessions: SessionInfo[];
|
|
183
283
|
#showCwd: boolean;
|
|
184
284
|
readonly #historyMatcher?: SessionHistoryMatcher;
|
|
285
|
+
#historyMergeTimer: NodeJS.Timeout | undefined;
|
|
286
|
+
/** Re-render hook for async list updates (fuzzy scan chunks, history merge). */
|
|
287
|
+
onRequestRender?: () => void;
|
|
288
|
+
|
|
289
|
+
// ── Incremental search state ──────────────────────────────────────────
|
|
290
|
+
// #filteredSessions is always composed from these three inputs (see
|
|
291
|
+
// #composeFiltered), so late-arriving fuzzy chunks and the debounced
|
|
292
|
+
// history merge can land in any order without clobbering each other.
|
|
293
|
+
/** Recency-ranked sessions whose text contains every query token verbatim. */
|
|
294
|
+
#literalRanked: RankedSessionMatch[] = [];
|
|
295
|
+
/** Score-ranked fuzzy-only matches, appended by scan chunks. */
|
|
296
|
+
#fuzzyRanked: RankedSessionMatch[] = [];
|
|
297
|
+
/** Prompt-history session IDs for the current query, once the merge landed. */
|
|
298
|
+
#historyIds: string[] = [];
|
|
299
|
+
/** Invalidates in-flight scan chunks when the query or dataset changes. */
|
|
300
|
+
#scanGeneration = 0;
|
|
301
|
+
#scanTimer: NodeJS.Timeout | undefined;
|
|
302
|
+
/**
|
|
303
|
+
* True once the user moved the selection for the current query; blocks the
|
|
304
|
+
* history merge from reordering the list under their cursor. (Fuzzy chunks
|
|
305
|
+
* only append below the literal group, which never shifts existing rows.)
|
|
306
|
+
*/
|
|
307
|
+
#selectionMoved = false;
|
|
185
308
|
|
|
186
309
|
constructor(
|
|
187
310
|
sessions: SessionInfo[],
|
|
@@ -233,23 +356,130 @@ class SessionList implements Component {
|
|
|
233
356
|
}
|
|
234
357
|
|
|
235
358
|
#filterSessions(query: string): void {
|
|
236
|
-
|
|
237
|
-
this.#
|
|
359
|
+
this.#scanGeneration++;
|
|
360
|
+
if (this.#scanTimer !== undefined) {
|
|
361
|
+
clearTimeout(this.#scanTimer);
|
|
362
|
+
this.#scanTimer = undefined;
|
|
363
|
+
}
|
|
364
|
+
this.#selectionMoved = false;
|
|
365
|
+
this.#historyIds = [];
|
|
366
|
+
this.#literalRanked = [];
|
|
367
|
+
this.#fuzzyRanked = [];
|
|
368
|
+
|
|
369
|
+
const tokens = tokenizeSessionQuery(query);
|
|
370
|
+
if (tokens.length === 0) {
|
|
371
|
+
this.#filteredSessions = this.#allSessions;
|
|
372
|
+
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
|
|
373
|
+
this.#scheduleHistoryMerge(query);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Literal pass: one substring scan per token per session, synchronous so
|
|
378
|
+
// every keystroke gets immediate recency-ranked feedback regardless of
|
|
379
|
+
// listing size.
|
|
380
|
+
const literal: RankedSessionMatch[] = [];
|
|
381
|
+
const rest: number[] = [];
|
|
382
|
+
const all = this.#allSessions;
|
|
383
|
+
for (let index = 0; index < all.length; index++) {
|
|
384
|
+
if (isLiteralMatch(sessionTextLower(all[index]!), tokens)) {
|
|
385
|
+
literal.push({ session: all[index]!, score: 0, index });
|
|
386
|
+
} else {
|
|
387
|
+
rest.push(index);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
literal.sort(compareLiteralRank);
|
|
391
|
+
this.#literalRanked = literal;
|
|
392
|
+
|
|
393
|
+
// Fuzzy pass: building a fuzzy index per session is too expensive to run
|
|
394
|
+
// across a huge listing inside one keystroke, so scan a bounded slice now
|
|
395
|
+
// and spill the remainder into async chunks.
|
|
396
|
+
this.#scanFuzzySlice(this.#scanGeneration, tokens, rest, 0, FUZZY_SCAN_INLINE_COUNT);
|
|
397
|
+
this.#composeFiltered();
|
|
398
|
+
this.#scheduleHistoryMerge(query);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Score up to `budget` sessions from `rest[start..]` (indexes into the
|
|
403
|
+
* unfiltered list), then schedule the remainder on a macrotask so pending
|
|
404
|
+
* input events run first. Chunks that added matches recompose the visible
|
|
405
|
+
* list and request a render; a stale generation aborts silently.
|
|
406
|
+
*/
|
|
407
|
+
#scanFuzzySlice(generation: number, tokens: string[], rest: number[], start: number, budget: number): void {
|
|
408
|
+
const all = this.#allSessions;
|
|
409
|
+
const end = Math.min(rest.length, start + budget);
|
|
410
|
+
for (let i = start; i < end; i++) {
|
|
411
|
+
const index = rest[i]!;
|
|
412
|
+
const session = all[index]!;
|
|
413
|
+
const match = scoreFuzzySession(session, index, tokens, new FuzzyText(sessionTextLower(session)));
|
|
414
|
+
if (match) this.#fuzzyRanked.push(match);
|
|
415
|
+
}
|
|
416
|
+
if (end >= rest.length) return;
|
|
417
|
+
this.#scanTimer = setTimeout(() => {
|
|
418
|
+
this.#scanTimer = undefined;
|
|
419
|
+
if (generation !== this.#scanGeneration) return;
|
|
420
|
+
const before = this.#fuzzyRanked.length;
|
|
421
|
+
this.#scanFuzzySlice(generation, tokens, rest, end, FUZZY_SCAN_CHUNK_COUNT);
|
|
422
|
+
if (this.#fuzzyRanked.length > before) {
|
|
423
|
+
this.#composeFiltered();
|
|
424
|
+
this.onRequestRender?.();
|
|
425
|
+
}
|
|
426
|
+
}, 0);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Rebuild {@link #filteredSessions} from the current literal, fuzzy, and
|
|
431
|
+
* history inputs: literal matches first (recency), fuzzy-only matches below
|
|
432
|
+
* (score), prompt-history matches promoted to the top when present.
|
|
433
|
+
*/
|
|
434
|
+
#composeFiltered(): void {
|
|
435
|
+
this.#fuzzyRanked.sort(compareFuzzyRank);
|
|
436
|
+
const base: SessionInfo[] = [];
|
|
437
|
+
for (const match of this.#literalRanked) base.push(match.session);
|
|
438
|
+
for (const match of this.#fuzzyRanked) base.push(match.session);
|
|
439
|
+
this.#filteredSessions =
|
|
440
|
+
this.#historyIds.length > 0 ? mergeSessionRanking(this.#allSessions, base, this.#historyIds) : base;
|
|
238
441
|
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
|
|
239
442
|
}
|
|
240
443
|
|
|
241
444
|
/**
|
|
242
|
-
* Augment
|
|
445
|
+
* Augment ranked results with prompt-history matches without replacing them.
|
|
243
446
|
* The session-list corpus only sees the first 4KB of each session, so a prompt
|
|
244
|
-
* typed deep into a long session is invisible to
|
|
245
|
-
* recovers those via `history.db`.
|
|
447
|
+
* typed deep into a long session is invisible to text search; `historyMatcher`
|
|
448
|
+
* recovers those via `history.db`. The lookup hits SQLite synchronously, so it
|
|
449
|
+
* is debounced off the keystroke path ({@link HISTORY_MERGE_DEBOUNCE_MS}) and
|
|
450
|
+
* composed in when it lands, discarded if the query changed meanwhile.
|
|
246
451
|
*/
|
|
247
|
-
#
|
|
452
|
+
#scheduleHistoryMerge(query: string): void {
|
|
453
|
+
if (this.#historyMergeTimer !== undefined) {
|
|
454
|
+
clearTimeout(this.#historyMergeTimer);
|
|
455
|
+
this.#historyMergeTimer = undefined;
|
|
456
|
+
}
|
|
457
|
+
const matcher = this.#historyMatcher;
|
|
248
458
|
const trimmed = query.trim();
|
|
249
|
-
if (!
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
459
|
+
if (!matcher || trimmed.length < HISTORY_MERGE_MIN_QUERY) return;
|
|
460
|
+
this.#historyMergeTimer = setTimeout(() => {
|
|
461
|
+
this.#historyMergeTimer = undefined;
|
|
462
|
+
if (this.#searchInput.getValue() !== query) return;
|
|
463
|
+
if (this.#selectionMoved) return;
|
|
464
|
+
const historyIds = matcher(trimmed);
|
|
465
|
+
if (historyIds.length === 0) return;
|
|
466
|
+
this.#historyIds = historyIds;
|
|
467
|
+
this.#composeFiltered();
|
|
468
|
+
this.onRequestRender?.();
|
|
469
|
+
}, HISTORY_MERGE_DEBOUNCE_MS);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Cancel pending async search work; idempotent, called on every picker exit path. */
|
|
473
|
+
dispose(): void {
|
|
474
|
+
this.#scanGeneration++;
|
|
475
|
+
if (this.#scanTimer !== undefined) {
|
|
476
|
+
clearTimeout(this.#scanTimer);
|
|
477
|
+
this.#scanTimer = undefined;
|
|
478
|
+
}
|
|
479
|
+
if (this.#historyMergeTimer !== undefined) {
|
|
480
|
+
clearTimeout(this.#historyMergeTimer);
|
|
481
|
+
this.#historyMergeTimer = undefined;
|
|
482
|
+
}
|
|
253
483
|
}
|
|
254
484
|
|
|
255
485
|
removeSession(sessionPath: string): void {
|
|
@@ -272,6 +502,7 @@ class SessionList implements Component {
|
|
|
272
502
|
/** Wheel notch: move the selection one step (clamped, no wrap). */
|
|
273
503
|
handleWheel(delta: -1 | 1): void {
|
|
274
504
|
if (this.#filteredSessions.length === 0) return;
|
|
505
|
+
this.#selectionMoved = true;
|
|
275
506
|
this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
|
|
276
507
|
}
|
|
277
508
|
|
|
@@ -433,21 +664,25 @@ class SessionList implements Component {
|
|
|
433
664
|
}
|
|
434
665
|
// Up arrow
|
|
435
666
|
if (matchesSelectUp(keyData)) {
|
|
667
|
+
this.#selectionMoved = true;
|
|
436
668
|
this.#selectedIndex = Math.max(0, this.#selectedIndex - 1);
|
|
437
669
|
return;
|
|
438
670
|
}
|
|
439
671
|
// Down arrow
|
|
440
672
|
if (matchesSelectDown(keyData)) {
|
|
673
|
+
this.#selectionMoved = true;
|
|
441
674
|
this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + 1);
|
|
442
675
|
return;
|
|
443
676
|
}
|
|
444
677
|
// Page up - jump up by maxVisible items
|
|
445
678
|
if (matchesKey(keyData, "pageUp")) {
|
|
679
|
+
this.#selectionMoved = true;
|
|
446
680
|
this.#selectedIndex = Math.max(0, this.#selectedIndex - this.#visibleCount());
|
|
447
681
|
return;
|
|
448
682
|
}
|
|
449
683
|
// Page down - jump down by maxVisible items
|
|
450
684
|
if (matchesKey(keyData, "pageDown")) {
|
|
685
|
+
this.#selectionMoved = true;
|
|
451
686
|
this.#selectedIndex = Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + this.#visibleCount());
|
|
452
687
|
return;
|
|
453
688
|
}
|
|
@@ -566,9 +801,21 @@ export class SessionSelectorComponent extends Container {
|
|
|
566
801
|
// user to Tab into all-projects rather than silently surfacing other
|
|
567
802
|
// projects' history (issue #3099).
|
|
568
803
|
this.#sessionList = new SessionList(sessions, false, options.historyMatcher, options.getTerminalRows);
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
this.#sessionList.
|
|
804
|
+
// Every exit path cancels the list's pending history merge, so a stale
|
|
805
|
+
// debounce timer can never run its SQLite lookup after the picker closed.
|
|
806
|
+
this.#sessionList.onSelect = session => {
|
|
807
|
+
this.#sessionList.dispose();
|
|
808
|
+
onSelect(session);
|
|
809
|
+
};
|
|
810
|
+
this.#sessionList.onCancel = () => {
|
|
811
|
+
this.#sessionList.dispose();
|
|
812
|
+
onCancel();
|
|
813
|
+
};
|
|
814
|
+
this.#sessionList.onExit = () => {
|
|
815
|
+
this.#sessionList.dispose();
|
|
816
|
+
onExit();
|
|
817
|
+
};
|
|
818
|
+
this.#sessionList.onRequestRender = () => this.#onRequestRender?.();
|
|
572
819
|
this.#sessionList.onDeleteRequest = (session: SessionInfo) => {
|
|
573
820
|
this.#showDeleteConfirmation(session);
|
|
574
821
|
};
|
|
@@ -628,6 +875,16 @@ export class SessionSelectorComponent extends Container {
|
|
|
628
875
|
this.#onRequestRender = callback;
|
|
629
876
|
}
|
|
630
877
|
|
|
878
|
+
/**
|
|
879
|
+
* Dispose the session list explicitly: while the delete-confirmation dialog
|
|
880
|
+
* is mounted the list is detached from the child tree, so Container's
|
|
881
|
+
* child-walking dispose would miss its pending history-merge timer.
|
|
882
|
+
*/
|
|
883
|
+
dispose(): void {
|
|
884
|
+
this.#sessionList.dispose();
|
|
885
|
+
super.dispose();
|
|
886
|
+
}
|
|
887
|
+
|
|
631
888
|
#clearError(): void {
|
|
632
889
|
this.#messageContainer.clear();
|
|
633
890
|
}
|
|
@@ -180,6 +180,8 @@ export interface ToolExecutionHandle extends Component {
|
|
|
180
180
|
): void;
|
|
181
181
|
setArgsComplete(toolCallId?: string): void;
|
|
182
182
|
setExpanded(expanded: boolean): void;
|
|
183
|
+
/** Freeze the block as final history: stop spinners and let it commit to scrollback. */
|
|
184
|
+
seal(): void;
|
|
183
185
|
}
|
|
184
186
|
|
|
185
187
|
/** Redraw live tool blocks at the spinner's glyph-advance rate. Rendering more
|
|
@@ -943,12 +943,18 @@ export class EventController {
|
|
|
943
943
|
if (component) {
|
|
944
944
|
const asyncState = (event.partialResult.details as { async?: { state?: string } } | undefined)?.async?.state;
|
|
945
945
|
const isFinalAsyncState = asyncState === "completed" || asyncState === "failed";
|
|
946
|
+
// A final async snapshot is terminal only for a parked background
|
|
947
|
+
// block (the call already returned and was kept alive for its jobs).
|
|
948
|
+
// While the call is still executing — a mixed blocking+async task
|
|
949
|
+
// call whose jobs settle before its blocking subset — treat it as a
|
|
950
|
+
// partial frame: `tool_execution_end` still owns the terminal result.
|
|
951
|
+
const isTerminal = isFinalAsyncState && this.#backgroundToolCallIds.has(event.toolCallId);
|
|
946
952
|
component.updateResult(
|
|
947
953
|
{ ...event.partialResult, isError: asyncState === "failed" },
|
|
948
|
-
!
|
|
954
|
+
!isTerminal,
|
|
949
955
|
event.toolCallId,
|
|
950
956
|
);
|
|
951
|
-
if (
|
|
957
|
+
if (isTerminal) {
|
|
952
958
|
this.ctx.pendingTools.delete(event.toolCallId);
|
|
953
959
|
this.#backgroundToolCallIds.delete(event.toolCallId);
|
|
954
960
|
}
|