@oh-my-pi/pi-coding-agent 15.10.1 → 15.10.3
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 +113 -1
- package/dist/types/cli/gallery-fixtures/types.d.ts +7 -1
- package/dist/types/cli/startup-cwd.d.ts +2 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-provider-priority.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +4 -1
- package/dist/types/config/settings.d.ts +7 -2
- package/dist/types/debug/report-bundle.d.ts +3 -0
- package/dist/types/edit/file-snapshot-store.d.ts +18 -10
- package/dist/types/edit/index.d.ts +0 -1
- package/dist/types/eval/py/__tests__/prelude.test.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +4 -1
- package/dist/types/lsp/client.d.ts +10 -0
- package/dist/types/lsp/index.d.ts +0 -5
- package/dist/types/main.d.ts +14 -9
- package/dist/types/mcp/tool-bridge.d.ts +2 -0
- package/dist/types/modes/components/assistant-message.d.ts +0 -9
- package/dist/types/modes/components/custom-editor.d.ts +1 -1
- package/dist/types/modes/components/late-diagnostics-message.d.ts +20 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-selector.d.ts +16 -7
- package/dist/types/modes/components/status-line.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +0 -18
- package/dist/types/modes/controllers/event-controller.d.ts +17 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/magic-keywords.d.ts +1 -1
- package/dist/types/modes/markdown-prose.d.ts +1 -1
- package/dist/types/modes/types.d.ts +7 -0
- package/dist/types/modes/workflow.d.ts +3 -3
- package/dist/types/session/auth-storage.d.ts +1 -1
- package/dist/types/session/messages.d.ts +11 -8
- package/dist/types/session/session-manager.d.ts +5 -2
- package/dist/types/session/yield-queue.d.ts +10 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/tools/eval-render.d.ts +0 -1
- package/dist/types/tools/eval.d.ts +8 -0
- package/dist/types/tools/gh-cache-invalidation.d.ts +6 -0
- package/dist/types/tools/github-cache.d.ts +12 -0
- package/dist/types/tools/index.d.ts +31 -0
- package/dist/types/tools/path-utils.d.ts +13 -1
- package/dist/types/tools/read.d.ts +2 -1
- package/dist/types/tools/render-utils.d.ts +3 -1
- package/dist/types/tools/renderers.d.ts +0 -15
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/tools/write.d.ts +0 -2
- package/dist/types/tools/yield.d.ts +8 -0
- package/dist/types/tui/code-cell.d.ts +0 -2
- package/dist/types/tui/hyperlink.d.ts +5 -7
- package/dist/types/tui/output-block.d.ts +0 -18
- package/package.json +9 -9
- package/src/cli/args.ts +3 -1
- package/src/cli/dry-balance-cli.ts +2 -4
- package/src/cli/gallery-cli.ts +4 -0
- package/src/cli/gallery-fixtures/codeintel.ts +0 -1
- package/src/cli/gallery-fixtures/fs.ts +68 -1
- package/src/cli/gallery-fixtures/types.ts +8 -1
- package/src/cli/startup-cwd.ts +68 -0
- package/src/commands/launch.ts +3 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/commit/model-selection.ts +3 -2
- package/src/config/model-provider-priority.ts +55 -0
- package/src/config/model-registry.ts +4 -22
- package/src/config/model-resolver.ts +39 -7
- package/src/config/settings.ts +86 -41
- package/src/debug/index.ts +8 -0
- package/src/debug/raw-sse-buffer.ts +7 -4
- package/src/debug/report-bundle.ts +9 -0
- package/src/edit/file-snapshot-store.ts +33 -1
- package/src/edit/hashline/diff.ts +86 -0
- package/src/edit/hashline/execute.ts +14 -1
- package/src/edit/hashline/filesystem.ts +2 -1
- package/src/edit/index.ts +31 -17
- package/src/edit/renderer.ts +116 -31
- package/src/eval/__tests__/llm-bridge.test.ts +20 -0
- package/src/eval/js/context-manager.ts +32 -15
- package/src/eval/js/shared/prelude.txt +26 -10
- package/src/eval/llm-bridge.ts +14 -3
- package/src/eval/py/__tests__/prelude.test.ts +19 -0
- package/src/eval/py/executor.ts +23 -11
- package/src/eval/py/prelude.py +1 -1
- package/src/extensibility/extensions/types.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +7 -7
- package/src/lsp/client.ts +23 -11
- package/src/lsp/config.ts +11 -1
- package/src/lsp/index.ts +189 -61
- package/src/main.ts +144 -78
- package/src/mcp/tool-bridge.ts +2 -0
- package/src/memories/index.ts +2 -2
- package/src/modes/components/assistant-message.ts +3 -15
- package/src/modes/components/custom-editor.ts +143 -111
- package/src/modes/components/late-diagnostics-message.ts +60 -0
- package/src/modes/components/model-selector.ts +59 -13
- package/src/modes/components/oauth-selector.ts +33 -7
- package/src/modes/components/plan-review-overlay.ts +26 -5
- package/src/modes/components/read-tool-group.ts +415 -35
- package/src/modes/components/session-selector.ts +89 -35
- package/src/modes/components/status-line.ts +19 -4
- package/src/modes/components/tips.txt +1 -1
- package/src/modes/components/tool-execution.ts +7 -49
- package/src/modes/components/transcript-container.ts +108 -32
- package/src/modes/components/user-message.ts +1 -1
- package/src/modes/controllers/event-controller.ts +32 -1
- package/src/modes/controllers/input-controller.ts +56 -9
- package/src/modes/interactive-mode.ts +107 -20
- package/src/modes/magic-keywords.ts +1 -1
- package/src/modes/markdown-prose.ts +1 -1
- package/src/modes/theme/shimmer.ts +20 -9
- package/src/modes/types.ts +7 -0
- package/src/modes/utils/ui-helpers.ts +26 -5
- package/src/modes/workflow.ts +10 -10
- package/src/prompts/system/manual-continue.md +7 -0
- package/src/prompts/system/plan-mode-active.md +56 -72
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/bash.md +9 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/eval.md +5 -2
- package/src/prompts/tools/lsp-late-diagnostic.md +8 -0
- package/src/prompts/tools/read.md +2 -2
- package/src/sdk.ts +85 -10
- package/src/session/agent-session.ts +42 -15
- package/src/session/auth-storage.ts +2 -0
- package/src/session/messages.ts +21 -14
- package/src/session/session-manager.ts +98 -25
- package/src/session/yield-queue.ts +20 -2
- package/src/task/executor.ts +72 -36
- package/src/task/render.ts +3 -4
- package/src/tiny/title-client.ts +6 -1
- package/src/tools/bash.ts +7 -7
- package/src/tools/browser/tab-supervisor.ts +13 -1
- package/src/tools/browser/tab-worker.ts +33 -4
- package/src/tools/eval-render.ts +4 -23
- package/src/tools/eval.ts +13 -2
- package/src/tools/find.ts +148 -99
- package/src/tools/gh-cache-invalidation.ts +200 -0
- package/src/tools/github-cache.ts +25 -0
- package/src/tools/index.ts +32 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/path-utils.ts +47 -24
- package/src/tools/plan-mode-guard.ts +52 -7
- package/src/tools/read.ts +41 -20
- package/src/tools/render-utils.ts +3 -1
- package/src/tools/renderers.ts +0 -15
- package/src/tools/search.ts +38 -3
- package/src/tools/ssh.ts +0 -1
- package/src/tools/todo.ts +1 -0
- package/src/tools/write.ts +5 -14
- package/src/tools/yield.ts +10 -1
- package/src/tui/code-cell.ts +1 -6
- package/src/tui/hyperlink.ts +13 -23
- package/src/tui/output-block.ts +2 -97
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/enhanced-paste.ts +30 -2
- package/src/web/search/providers/codex.ts +37 -8
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Component,
|
|
3
3
|
Container,
|
|
4
|
-
|
|
4
|
+
fuzzyMatch,
|
|
5
5
|
Input,
|
|
6
6
|
matchesKey,
|
|
7
7
|
padding,
|
|
@@ -46,43 +46,107 @@ function formatSessionStatus(status: SessionStatus | undefined): string | undefi
|
|
|
46
46
|
/** Returns the IDs of sessions whose recorded prompts match a query, best first. */
|
|
47
47
|
export type SessionHistoryMatcher = (query: string) => string[];
|
|
48
48
|
|
|
49
|
+
function sessionSearchText(session: SessionInfo): string {
|
|
50
|
+
const parts = [
|
|
51
|
+
session.id,
|
|
52
|
+
session.title ?? "",
|
|
53
|
+
session.cwd ?? "",
|
|
54
|
+
session.firstMessage ?? "",
|
|
55
|
+
session.allMessagesText,
|
|
56
|
+
session.path,
|
|
57
|
+
];
|
|
58
|
+
return parts.filter(Boolean).join(" ");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tokenizeSessionQuery(query: string): string[] {
|
|
62
|
+
const trimmed = query.trim().toLowerCase();
|
|
63
|
+
return trimmed ? trimmed.split(/\s+/) : [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function compareSessionRecency(a: SessionInfo, b: SessionInfo): number {
|
|
67
|
+
return b.modified.getTime() - a.modified.getTime();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Filter and rank session picker search results.
|
|
72
|
+
*
|
|
73
|
+
* Resume search narrows a recency-sorted list: once every query token appears
|
|
74
|
+
* as a literal substring, newer sessions should beat a slightly better fuzzy
|
|
75
|
+
* position match. Pure fuzzy/acronym matches still sort by fuzzy score after
|
|
76
|
+
* literal matches.
|
|
77
|
+
*/
|
|
78
|
+
export function rankSessionSearchMatches(allSessions: SessionInfo[], query: string): SessionInfo[] {
|
|
79
|
+
const tokens = tokenizeSessionQuery(query);
|
|
80
|
+
if (tokens.length === 0) return allSessions;
|
|
81
|
+
|
|
82
|
+
const results: Array<{ session: SessionInfo; score: number; literal: boolean; index: number }> = [];
|
|
83
|
+
for (let index = 0; index < allSessions.length; index++) {
|
|
84
|
+
const session = allSessions[index]!;
|
|
85
|
+
const text = sessionSearchText(session);
|
|
86
|
+
const textLower = text.toLowerCase();
|
|
87
|
+
let score = 0;
|
|
88
|
+
let literal = true;
|
|
89
|
+
let matches = true;
|
|
90
|
+
|
|
91
|
+
for (const token of tokens) {
|
|
92
|
+
const match = fuzzyMatch(token, textLower);
|
|
93
|
+
if (!match.matches) {
|
|
94
|
+
matches = false;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
score += match.score;
|
|
98
|
+
if (!textLower.includes(token)) literal = false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (matches) results.push({ session, score, literal, index });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
results.sort((a, b) => {
|
|
105
|
+
if (a.literal !== b.literal) return a.literal ? -1 : 1;
|
|
106
|
+
if (a.literal) return compareSessionRecency(a.session, b.session) || a.index - b.index;
|
|
107
|
+
return a.score - b.score || compareSessionRecency(a.session, b.session) || a.index - b.index;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return results.map(result => result.session);
|
|
111
|
+
}
|
|
112
|
+
|
|
49
113
|
/**
|
|
50
|
-
* Combine
|
|
51
|
-
*
|
|
114
|
+
* Combine metadata matches with prompt-history matches for ranking, using both
|
|
115
|
+
* signals rather than replacing one with the other.
|
|
52
116
|
*
|
|
53
|
-
* - `fuzzy` is the ordered
|
|
117
|
+
* - `fuzzy` is the ordered metadata/session-text result.
|
|
54
118
|
* - `historyIds` are session IDs whose recorded prompts matched the query,
|
|
55
119
|
* ordered by prompt-history rank (typically newest matching prompt first); duplicates are tolerated.
|
|
56
120
|
*
|
|
57
|
-
* Ranking:
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
121
|
+
* Ranking: prompt-history matches lead in history order, then remaining
|
|
122
|
+
* metadata matches keep their existing order. A metadata match is never dropped,
|
|
123
|
+
* and history matches not present in `allSessions` (e.g. deleted or out-of-scope
|
|
124
|
+
* sessions) are ignored since they cannot be resumed from here.
|
|
61
125
|
*/
|
|
62
126
|
export function mergeSessionRanking(
|
|
63
127
|
allSessions: SessionInfo[],
|
|
64
128
|
fuzzy: SessionInfo[],
|
|
65
129
|
historyIds: string[],
|
|
66
130
|
): SessionInfo[] {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const both: SessionInfo[] = [];
|
|
74
|
-
const fuzzyOnly: SessionInfo[] = [];
|
|
75
|
-
const fuzzyPaths = new Set<string>();
|
|
76
|
-
for (const session of fuzzy) {
|
|
77
|
-
fuzzyPaths.add(session.path);
|
|
78
|
-
(historyRank.has(session.id) ? both : fuzzyOnly).push(session);
|
|
131
|
+
if (historyIds.length === 0) return fuzzy;
|
|
132
|
+
|
|
133
|
+
const sessionsById = new Map<string, SessionInfo>();
|
|
134
|
+
for (const session of allSessions) {
|
|
135
|
+
if (!sessionsById.has(session.id)) sessionsById.set(session.id, session);
|
|
79
136
|
}
|
|
80
137
|
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
138
|
+
const historyMatches: SessionInfo[] = [];
|
|
139
|
+
const historyPaths = new Set<string>();
|
|
140
|
+
for (const id of historyIds) {
|
|
141
|
+
const session = sessionsById.get(id);
|
|
142
|
+
if (!session || historyPaths.has(session.path)) continue;
|
|
143
|
+
historyMatches.push(session);
|
|
144
|
+
historyPaths.add(session.path);
|
|
145
|
+
}
|
|
146
|
+
if (historyMatches.length === 0) return fuzzy;
|
|
84
147
|
|
|
85
|
-
|
|
148
|
+
const metadataOnly = fuzzy.filter(session => !historyPaths.has(session.path));
|
|
149
|
+
return [...historyMatches, ...metadataOnly];
|
|
86
150
|
}
|
|
87
151
|
|
|
88
152
|
/**
|
|
@@ -156,17 +220,7 @@ class SessionList implements Component {
|
|
|
156
220
|
}
|
|
157
221
|
|
|
158
222
|
#filterSessions(query: string): void {
|
|
159
|
-
const fuzzy =
|
|
160
|
-
const parts = [
|
|
161
|
-
session.id,
|
|
162
|
-
session.title ?? "",
|
|
163
|
-
session.cwd ?? "",
|
|
164
|
-
session.firstMessage ?? "",
|
|
165
|
-
session.allMessagesText,
|
|
166
|
-
session.path,
|
|
167
|
-
];
|
|
168
|
-
return parts.filter(Boolean).join(" ");
|
|
169
|
-
});
|
|
223
|
+
const fuzzy = rankSessionSearchMatches(this.#allSessions, query);
|
|
170
224
|
this.#filteredSessions = this.#mergeHistoryMatches(query, fuzzy);
|
|
171
225
|
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, this.#filteredSessions.length - 1));
|
|
172
226
|
}
|
|
@@ -40,6 +40,11 @@ export interface StatusLineSettings {
|
|
|
40
40
|
sessionAccent?: boolean;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export type EffectiveStatusLineSettings = Required<
|
|
44
|
+
Pick<StatusLineSettings, "leftSegments" | "rightSegments" | "separator" | "segmentOptions">
|
|
45
|
+
> &
|
|
46
|
+
StatusLineSettings;
|
|
47
|
+
|
|
43
48
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
49
|
// Per-message token cache
|
|
45
50
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -143,6 +148,7 @@ function tokensForMessage(msg: AgentMessage): number {
|
|
|
143
148
|
|
|
144
149
|
export class StatusLineComponent implements Component {
|
|
145
150
|
#settings: StatusLineSettings = {};
|
|
151
|
+
#effectiveSettings: EffectiveStatusLineSettings | undefined;
|
|
146
152
|
#cachedBranch: string | null | undefined = undefined;
|
|
147
153
|
#cachedBranchRepoId: string | null | undefined = undefined;
|
|
148
154
|
#gitWatcher: fs.FSWatcher | null = null;
|
|
@@ -204,6 +210,11 @@ export class StatusLineComponent implements Component {
|
|
|
204
210
|
|
|
205
211
|
updateSettings(settings: StatusLineSettings): void {
|
|
206
212
|
this.#settings = settings;
|
|
213
|
+
this.#effectiveSettings = undefined;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
getEffectiveSettingsForTest(): EffectiveStatusLineSettings {
|
|
217
|
+
return this.#resolveSettings();
|
|
207
218
|
}
|
|
208
219
|
|
|
209
220
|
setAutoCompactEnabled(enabled: boolean): void {
|
|
@@ -594,10 +605,14 @@ export class StatusLineComponent implements Component {
|
|
|
594
605
|
};
|
|
595
606
|
}
|
|
596
607
|
|
|
597
|
-
#resolveSettings():
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
608
|
+
#resolveSettings(): EffectiveStatusLineSettings {
|
|
609
|
+
if (this.#effectiveSettings === undefined) {
|
|
610
|
+
this.#effectiveSettings = this.#computeEffectiveSettings();
|
|
611
|
+
}
|
|
612
|
+
return this.#effectiveSettings;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
#computeEffectiveSettings(): EffectiveStatusLineSettings {
|
|
601
616
|
const preset = this.#settings.preset ?? "default";
|
|
602
617
|
const presetDef = getPreset(preset);
|
|
603
618
|
const useCustomSegments = preset === "custom";
|
|
@@ -9,7 +9,7 @@ Spaghetti code? Try complaining with /omfg
|
|
|
9
9
|
Did you know? Each kitty/tmux/cmux split keeps its own session — `omp -c` resumes the right one
|
|
10
10
|
Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
|
|
11
11
|
Say `orchestrate` in your message to drive a multi-phase task with parallel subagents — watch it glow as you type
|
|
12
|
-
Say `
|
|
12
|
+
Say `workflowz` in your message to drive the task with parallel subagents in eval — watch it glow as you type
|
|
13
13
|
Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
|
|
14
14
|
Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
|
|
15
15
|
Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
|
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
} from "@oh-my-pi/pi-tui";
|
|
16
16
|
import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
17
17
|
import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
|
|
18
|
-
import { shimmerEnabled } from "../../modes/theme/shimmer";
|
|
19
18
|
import type { Theme } from "../../modes/theme/theme";
|
|
20
19
|
import { theme } from "../../modes/theme/theme";
|
|
21
20
|
import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
|
|
@@ -31,7 +30,7 @@ import {
|
|
|
31
30
|
renderJsonTreeLines,
|
|
32
31
|
} from "../../tools/json-tree";
|
|
33
32
|
import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } from "../../tools/render-utils";
|
|
34
|
-
import {
|
|
33
|
+
import { toolRenderers } from "../../tools/renderers";
|
|
35
34
|
import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
|
|
36
35
|
import { isFramedBlockComponent, renderStatusLine } from "../../tui";
|
|
37
36
|
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
|
@@ -133,9 +132,10 @@ export interface ToolExecutionHandle {
|
|
|
133
132
|
setExpanded(expanded: boolean): void;
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
/** Drive pending-tool redraws at 30fps so the
|
|
137
|
-
* smooth without spending twice the frame budget. The TUI
|
|
138
|
-
* cadence, and static frames diff to a no-op redraw at
|
|
135
|
+
/** Drive pending-tool redraws at 30fps so the running `task` row's shimmered
|
|
136
|
+
* subagent name stays smooth without spending twice the frame budget. The TUI
|
|
137
|
+
* throttles at the same cadence, and static frames diff to a no-op redraw at
|
|
138
|
+
* ~zero cost. */
|
|
139
139
|
const SPINNER_RENDER_INTERVAL_MS = 1000 / 30;
|
|
140
140
|
/** Advance the spinner glyph at its classic ~12.5fps step, decoupled from the
|
|
141
141
|
* render cadence (mirrors `Loader`). */
|
|
@@ -425,16 +425,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
425
425
|
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
|
426
426
|
const isBackgroundAsyncTask = this.#toolName === "task" && isBackgroundAsyncRunning;
|
|
427
427
|
const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
|
|
428
|
-
|
|
429
|
-
// not once they've been backgrounded: a backgrounded job's block gets
|
|
430
|
-
// committed to scrollback and finalizes later via the async update path, so a
|
|
431
|
-
// mid-sweep frame would freeze a stray dark "bar" segment into the border.
|
|
432
|
-
const isPendingExecBlock =
|
|
433
|
-
this.#isPartial &&
|
|
434
|
-
shimmerEnabled() &&
|
|
435
|
-
(this.#toolName === "bash" || this.#toolName === "eval") &&
|
|
436
|
-
!isBackgroundAsyncRunning;
|
|
437
|
-
const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
|
|
428
|
+
const needsSpinner = isStreamingArgs || isPartialTask;
|
|
438
429
|
if (needsSpinner && !this.#spinnerInterval) {
|
|
439
430
|
const now = performance.now();
|
|
440
431
|
const frameCount = theme.spinnerFrames.length;
|
|
@@ -446,7 +437,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
446
437
|
this.#spinnerInterval = setInterval(() => {
|
|
447
438
|
const now = performance.now();
|
|
448
439
|
const frameCount = theme.spinnerFrames.length;
|
|
449
|
-
// Redraw at 30fps for a smooth
|
|
440
|
+
// Redraw at 30fps for a smooth `task` name shimmer, but keep the spinner
|
|
450
441
|
// glyph phase-locked to its classic ~12.5fps cadence. Advancing the
|
|
451
442
|
// anchor by elapsed frames instead of resetting to `now` avoids the
|
|
452
443
|
// 30fps timer quantizing the glyph down to one step every three ticks.
|
|
@@ -529,39 +520,6 @@ export class ToolExecutionComponent extends Container {
|
|
|
529
520
|
return (this.#result.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
|
530
521
|
}
|
|
531
522
|
|
|
532
|
-
/**
|
|
533
|
-
* While a tool's preview is still streaming, a block whose preview is
|
|
534
|
-
* append-only (rows only grow at the bottom, never re-layout) lets the
|
|
535
|
-
* renderer commit the scrolled-off head of an over-tall preview to native
|
|
536
|
-
* scrollback instead of dropping it — the same anti-yank path a streaming
|
|
537
|
-
* assistant reply uses (see {@link TranscriptContainer} +
|
|
538
|
-
* `NativeScrollbackLiveRegion`). Covers both phases: a pre-result call preview
|
|
539
|
-
* (a `write` whose content streams in) and a partial-result preview that
|
|
540
|
-
* streams output below fixed input (an `eval`/`bash` whose stdout grows under
|
|
541
|
-
* its code cell). Gated on {@link isTranscriptBlockFinalized} so the boundary
|
|
542
|
-
* closes the instant the block reaches a terminal state — a final result that
|
|
543
|
-
* may collapse to a compact view, a backgrounded async tool, or a seal — and
|
|
544
|
-
* the renderer decides whether its current preview shape qualifies via
|
|
545
|
-
* `isStreamingPreviewAppendOnly` (typically: only the expanded full view,
|
|
546
|
-
* which is top-anchored; the collapsed tail window re-layouts but is bounded
|
|
547
|
-
* so it never overflows anyway).
|
|
548
|
-
*/
|
|
549
|
-
isTranscriptBlockAppendOnly(): boolean {
|
|
550
|
-
// A finalized block's preview can collapse/re-layout; only a live,
|
|
551
|
-
// still-streaming block is a candidate.
|
|
552
|
-
if (this.isTranscriptBlockFinalized()) return false;
|
|
553
|
-
const predicate =
|
|
554
|
-
(this.#tool as { isStreamingPreviewAppendOnly?: ToolRenderer["isStreamingPreviewAppendOnly"] } | undefined)
|
|
555
|
-
?.isStreamingPreviewAppendOnly ?? toolRenderers[this.#toolName]?.isStreamingPreviewAppendOnly;
|
|
556
|
-
if (!predicate) return false;
|
|
557
|
-
try {
|
|
558
|
-
return predicate(this.#getCallArgsForRender(), this.#renderState, this.#result);
|
|
559
|
-
} catch (err) {
|
|
560
|
-
logger.warn("Tool append-only predicate failed", { tool: this.#toolName, error: String(err) });
|
|
561
|
-
return false;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
|
|
565
523
|
/**
|
|
566
524
|
* Mark the tool terminal even though no result arrived (the turn aborted or
|
|
567
525
|
* abandoned it) and stop animating, so it can freeze and stops pinning the
|
|
@@ -6,6 +6,8 @@ interface FrozenRender {
|
|
|
6
6
|
width: number;
|
|
7
7
|
lines: string[];
|
|
8
8
|
generation: number;
|
|
9
|
+
appendOnly: boolean;
|
|
10
|
+
volatile: boolean;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
interface SnapshotCarrier {
|
|
@@ -17,16 +19,9 @@ interface SnapshotCarrier {
|
|
|
17
19
|
* result, an assistant message mid-stream) reports `false` so the container
|
|
18
20
|
* keeps it inside the live (repaintable) region instead of freezing it. Blocks
|
|
19
21
|
* without the method are treated as finalized — the default, stable behavior.
|
|
20
|
-
*
|
|
21
|
-
* `isTranscriptBlockAppendOnly` marks a still-live block whose rendered rows
|
|
22
|
-
* only grow at the bottom and never re-layout (a streaming assistant reply).
|
|
23
|
-
* Such a block's scrolled-off head is safe to commit to native scrollback even
|
|
24
|
-
* while live; blocks that omit it (tool previews that collapse to a compact
|
|
25
|
-
* result) keep their mutable rows deferred. Default is `false`.
|
|
26
22
|
*/
|
|
27
23
|
interface FinalizableBlock {
|
|
28
24
|
isTranscriptBlockFinalized?(): boolean;
|
|
29
|
-
isTranscriptBlockAppendOnly?(): boolean;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
function isBlockFinalized(child: Component): boolean {
|
|
@@ -34,11 +29,6 @@ function isBlockFinalized(child: Component): boolean {
|
|
|
34
29
|
return fn ? fn.call(child) : true;
|
|
35
30
|
}
|
|
36
31
|
|
|
37
|
-
function isBlockAppendOnly(child: Component): boolean {
|
|
38
|
-
const fn = (child as Component & FinalizableBlock).isTranscriptBlockAppendOnly;
|
|
39
|
-
return fn ? fn.call(child) : false;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
32
|
// A "plain blank" row is empty or whitespace-only with no ANSI bytes. It marks
|
|
43
33
|
// separation padding (a `Spacer`, or a no-background `paddingY` row) as opposed
|
|
44
34
|
// to a background-colored padding row, whose escape sequences contain `\S` and
|
|
@@ -59,6 +49,73 @@ function stripPlainBlankEdges(lines: string[]): string[] {
|
|
|
59
49
|
return start === 0 && end === lines.length ? lines : lines.slice(start, end);
|
|
60
50
|
}
|
|
61
51
|
|
|
52
|
+
interface LiveCommitState {
|
|
53
|
+
appendOnly: boolean;
|
|
54
|
+
volatile: boolean;
|
|
55
|
+
safeLength: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hasValidSnapshot(
|
|
59
|
+
snapshot: FrozenRender | undefined,
|
|
60
|
+
width: number,
|
|
61
|
+
generation: number,
|
|
62
|
+
): snapshot is FrozenRender {
|
|
63
|
+
return snapshot !== undefined && snapshot.generation === generation && snapshot.width === width;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commonPrefixLength(prev: string[], cur: string[]): number {
|
|
67
|
+
const limit = Math.min(prev.length, cur.length);
|
|
68
|
+
let i = 0;
|
|
69
|
+
while (i < limit && prev[i] === cur[i]) i++;
|
|
70
|
+
return i;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
|
|
74
|
+
const prevLimit = prev.length - prefixLength;
|
|
75
|
+
const curLimit = cur.length - prefixLength;
|
|
76
|
+
const limit = Math.min(prevLimit, curLimit);
|
|
77
|
+
let i = 0;
|
|
78
|
+
while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
|
|
79
|
+
return i;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function deriveLiveCommitState(
|
|
83
|
+
previous: FrozenRender | undefined,
|
|
84
|
+
current: string[],
|
|
85
|
+
width: number,
|
|
86
|
+
generation: number,
|
|
87
|
+
): LiveCommitState {
|
|
88
|
+
let appendOnly = false;
|
|
89
|
+
let volatile = false;
|
|
90
|
+
if (hasValidSnapshot(previous, width, generation)) {
|
|
91
|
+
appendOnly = previous.appendOnly;
|
|
92
|
+
volatile = previous.volatile;
|
|
93
|
+
|
|
94
|
+
const prefixLength = commonPrefixLength(previous.lines, current);
|
|
95
|
+
const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
|
|
96
|
+
if (!staticRender) {
|
|
97
|
+
const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
|
|
98
|
+
const stablePreviousLength = prefixLength + suffixLength;
|
|
99
|
+
const appendGrew =
|
|
100
|
+
previous.lines.length > 0 &&
|
|
101
|
+
current.length > previous.lines.length &&
|
|
102
|
+
stablePreviousLength >= previous.lines.length;
|
|
103
|
+
if (appendGrew && !volatile) {
|
|
104
|
+
appendOnly = true;
|
|
105
|
+
} else if (stablePreviousLength < previous.lines.length) {
|
|
106
|
+
volatile = true;
|
|
107
|
+
appendOnly = false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
appendOnly,
|
|
114
|
+
volatile,
|
|
115
|
+
safeLength: volatile ? 0 : appendOnly ? current.length : 0,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
62
119
|
/**
|
|
63
120
|
* Transcript container that freezes the rendered output of every block except
|
|
64
121
|
* the bottom-most (live) one on terminals where committed native scrollback is
|
|
@@ -97,11 +154,10 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
97
154
|
// render. TUI extends the native-scrollback pinned region from this point
|
|
98
155
|
// through the live blocks and the root chrome rendered below them.
|
|
99
156
|
#nativeScrollbackLiveRegionStart: number | undefined;
|
|
100
|
-
// Local line index up to which the leading run of live blocks is
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// the first live block is volatile (a tool preview).
|
|
157
|
+
// Local line index up to which the leading run of live blocks is safe to
|
|
158
|
+
// commit. Finalized blocks contribute their full frozen body; still-live
|
|
159
|
+
// blocks contribute only after their stripped render has been observed
|
|
160
|
+
// growing without changing a previously rendered interior row.
|
|
105
161
|
#nativeScrollbackCommitSafeEnd: number | undefined;
|
|
106
162
|
|
|
107
163
|
override invalidate(): void {
|
|
@@ -164,8 +220,9 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
164
220
|
if (risk) this.#prevLiveStartIndex = liveStartIndex;
|
|
165
221
|
|
|
166
222
|
const lines: string[] = [];
|
|
167
|
-
// Tracks whether we are still inside the leading run of
|
|
168
|
-
// blocks. The first
|
|
223
|
+
// Tracks whether we are still inside the leading run of commit-safe live
|
|
224
|
+
// blocks. The first still-live volatile block closes it, but rendering
|
|
225
|
+
// continues so lower blocks remain visible.
|
|
169
226
|
let commitSafeOpen = true;
|
|
170
227
|
// The live-region start is recorded at the first visible row at/after the
|
|
171
228
|
// cutoff; empty leading blocks (or a separator) must not claim it early.
|
|
@@ -179,24 +236,41 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
179
236
|
// instead of recomputing; a stale generation (post-thaw) or width
|
|
180
237
|
// mismatch (resize) recomputes, as does a block still live last frame.
|
|
181
238
|
let contribution: string[] | undefined;
|
|
239
|
+
const previousSnapshot = risk ? child[kSnapshot] : undefined;
|
|
182
240
|
if (risk && i < liveStartIndex && i < replayCutoff) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
contribution = snapshot.lines;
|
|
241
|
+
if (hasValidSnapshot(previousSnapshot, width, this.#generation)) {
|
|
242
|
+
contribution = previousSnapshot.lines;
|
|
186
243
|
}
|
|
187
244
|
}
|
|
245
|
+
let liveCommitState: LiveCommitState | undefined;
|
|
188
246
|
if (contribution === undefined) {
|
|
189
247
|
const rendered = child.render(width);
|
|
190
248
|
contribution = stripPlainBlankEdges(rendered);
|
|
249
|
+
if (risk && i >= liveStartIndex && !isBlockFinalized(child)) {
|
|
250
|
+
liveCommitState = deriveLiveCommitState(previousSnapshot, contribution, width, this.#generation);
|
|
251
|
+
}
|
|
191
252
|
// Cache every block's latest contribution. While a block is in the
|
|
192
253
|
// live region this keeps its snapshot current; on the frame it crosses
|
|
193
254
|
// out, the recompute above refreshes it before it freezes.
|
|
194
|
-
if (risk)
|
|
255
|
+
if (risk) {
|
|
256
|
+
child[kSnapshot] = {
|
|
257
|
+
width,
|
|
258
|
+
lines: contribution,
|
|
259
|
+
generation: this.#generation,
|
|
260
|
+
appendOnly: liveCommitState?.appendOnly ?? false,
|
|
261
|
+
volatile: liveCommitState?.volatile ?? false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
195
264
|
}
|
|
196
265
|
|
|
197
266
|
// Empty (or stripped-to-nothing) children contribute nothing and never
|
|
198
|
-
// affect spacing or the live-region offsets.
|
|
199
|
-
if
|
|
267
|
+
// affect spacing or the live-region offsets. An empty still-live child
|
|
268
|
+
// still closes the commit-safe run: if it later gains rows, it pushes
|
|
269
|
+
// everything below it.
|
|
270
|
+
if (contribution.length === 0) {
|
|
271
|
+
if (risk && i >= liveStartIndex && commitSafeOpen && !isBlockFinalized(child)) commitSafeOpen = false;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
200
274
|
|
|
201
275
|
// Every block is separated from preceding visible content by exactly one
|
|
202
276
|
// blank row — skipped when it opens the transcript or the prior row is
|
|
@@ -212,17 +286,19 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
|
|
|
212
286
|
}
|
|
213
287
|
|
|
214
288
|
if (sep) lines.push("");
|
|
289
|
+
const blockStart = lines.length;
|
|
215
290
|
for (let j = 0; j < contribution.length; j++) lines.push(contribution[j]!);
|
|
216
291
|
|
|
217
|
-
// Extend the commit-safe boundary through each leading append-only live
|
|
218
|
-
// block. The first volatile live block closes the run so its mutable
|
|
219
|
-
// rows stay deferred.
|
|
220
292
|
if (risk && i >= liveStartIndex && commitSafeOpen) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
293
|
+
const finalized = isBlockFinalized(child);
|
|
294
|
+
const safeLength = finalized ? contribution.length : (liveCommitState?.safeLength ?? 0);
|
|
295
|
+
if (safeLength > 0) {
|
|
296
|
+
this.#nativeScrollbackCommitSafeEnd = blockStart + safeLength;
|
|
225
297
|
}
|
|
298
|
+
// A finalized, fully safe block may let the contiguous safe run extend
|
|
299
|
+
// into blocks rendered below it. A still-live block keeps pushing lower
|
|
300
|
+
// rows around as it grows, so the run closes there.
|
|
301
|
+
if (!(finalized && safeLength >= contribution.length)) commitSafeOpen = false;
|
|
226
302
|
}
|
|
227
303
|
}
|
|
228
304
|
return lines;
|
|
@@ -15,7 +15,7 @@ export class UserMessageComponent extends Container {
|
|
|
15
15
|
constructor(text: string, synthetic = false, imageLinks?: readonly (string | undefined)[]) {
|
|
16
16
|
super();
|
|
17
17
|
const bgColor = (value: string) => theme.bg("userMessageBg", value);
|
|
18
|
-
// Paint the magic keywords ("ultrathink"/"orchestrate"/"
|
|
18
|
+
// Paint the magic keywords ("ultrathink"/"orchestrate"/"workflowz") inside the rendered
|
|
19
19
|
// bubble too — matching the live editor glow. The Markdown component routes code spans and
|
|
20
20
|
// fenced blocks through its own code styling (never `color`), so those are already excluded;
|
|
21
21
|
// `highlightMagicKeywords` additionally restores the bubble's own foreground after each
|
|
@@ -26,6 +26,16 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
|
|
|
26
26
|
|
|
27
27
|
const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Loader label shown the instant a user interrupt (Esc) is requested, kept until
|
|
31
|
+
* the agent turn fully unwinds. Esc fires the abort synchronously, but the loop
|
|
32
|
+
* only stops the spinner at `agent_end`, which it cannot reach until every
|
|
33
|
+
* in-flight tool settles its abort in `executeToolCalls` (`Promise.allSettled`).
|
|
34
|
+
* Swapping the steady "Working…" for this acknowledges the keypress instead of
|
|
35
|
+
* reading as an ignored Esc for the seconds a slow tool takes to tear down.
|
|
36
|
+
*/
|
|
37
|
+
export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
|
|
38
|
+
|
|
29
39
|
// Events that change foreground streaming state, or that reset a turn. The TUI
|
|
30
40
|
// eager native-scrollback rebuild mode is recomputed only on these so unrelated
|
|
31
41
|
// IRC/notices/status refreshes do not toggle scrollback replay policy.
|
|
@@ -57,6 +67,7 @@ export class EventController {
|
|
|
57
67
|
#backgroundToolCallIds = new Set<string>();
|
|
58
68
|
#assistantMessageStreaming = false;
|
|
59
69
|
#agentTurnActive = false;
|
|
70
|
+
#interrupting = false;
|
|
60
71
|
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
|
61
72
|
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
|
62
73
|
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
|
@@ -167,6 +178,7 @@ export class EventController {
|
|
|
167
178
|
return true;
|
|
168
179
|
}
|
|
169
180
|
#updateWorkingMessageFromIntent(intent: unknown): void {
|
|
181
|
+
if (this.#interrupting) return;
|
|
170
182
|
// Streamed JSON can deliver non-string `_i` (object, number, boolean) before
|
|
171
183
|
// schema validation; `?.` only guards null/undefined, so guard the type too.
|
|
172
184
|
if (typeof intent !== "string") return;
|
|
@@ -176,6 +188,19 @@ export class EventController {
|
|
|
176
188
|
this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Acknowledge a user interrupt (Esc) immediately: switch the loader to
|
|
193
|
+
* `INTERRUPTING_WORKING_MESSAGE` and freeze intent-driven working-message
|
|
194
|
+
* updates for the rest of the turn so a late `tool_execution_start` intent
|
|
195
|
+
* cannot repaint a "Working…/<intent>" line over the acknowledgment. Reset at
|
|
196
|
+
* the next `agent_start`. No-op outside an active turn or if already set.
|
|
197
|
+
*/
|
|
198
|
+
notifyInterrupting(): void {
|
|
199
|
+
if (!this.#agentTurnActive || this.#interrupting) return;
|
|
200
|
+
this.#interrupting = true;
|
|
201
|
+
this.ctx.setWorkingMessage(INTERRUPTING_WORKING_MESSAGE);
|
|
202
|
+
}
|
|
203
|
+
|
|
179
204
|
subscribeToAgent(): void {
|
|
180
205
|
this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
|
|
181
206
|
await this.handleEvent(event);
|
|
@@ -220,6 +245,7 @@ export class EventController {
|
|
|
220
245
|
|
|
221
246
|
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
|
222
247
|
this.#agentTurnActive = true;
|
|
248
|
+
this.#interrupting = false;
|
|
223
249
|
this.#lastIntent = undefined;
|
|
224
250
|
this.#readToolCallArgs.clear();
|
|
225
251
|
this.#readToolCallAssistantComponents.clear();
|
|
@@ -694,7 +720,12 @@ export class EventController {
|
|
|
694
720
|
// seal it so it freezes (and stops animating) rather than lingering in
|
|
695
721
|
// the transcript live region as a streaming preview until the next thaw.
|
|
696
722
|
const component = this.ctx.pendingTools.get(toolCallId);
|
|
697
|
-
|
|
723
|
+
// A foreground read still pending at turn end shares a group component
|
|
724
|
+
// keyed by every read's id; seal it too so a never-delivered read does
|
|
725
|
+
// not keep the group live (and pinning the live region) indefinitely.
|
|
726
|
+
if (component instanceof ToolExecutionComponent || component instanceof ReadToolGroupComponent) {
|
|
727
|
+
component.seal();
|
|
728
|
+
}
|
|
698
729
|
this.ctx.pendingTools.delete(toolCallId);
|
|
699
730
|
}
|
|
700
731
|
}
|