@oh-my-pi/pi-coding-agent 13.14.2 → 13.15.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 +150 -0
- package/package.json +10 -8
- package/src/autoresearch/command-initialize.md +34 -0
- package/src/autoresearch/command-resume.md +17 -0
- package/src/autoresearch/contract.ts +332 -0
- package/src/autoresearch/dashboard.ts +447 -0
- package/src/autoresearch/git.ts +243 -0
- package/src/autoresearch/helpers.ts +458 -0
- package/src/autoresearch/index.ts +693 -0
- package/src/autoresearch/prompt.md +227 -0
- package/src/autoresearch/resume-message.md +16 -0
- package/src/autoresearch/state.ts +386 -0
- package/src/autoresearch/tools/init-experiment.ts +310 -0
- package/src/autoresearch/tools/log-experiment.ts +833 -0
- package/src/autoresearch/tools/run-experiment.ts +640 -0
- package/src/autoresearch/types.ts +218 -0
- package/src/cli/args.ts +8 -2
- package/src/cli/initial-message.ts +58 -0
- package/src/config/keybindings.ts +423 -212
- package/src/config/model-registry.ts +1 -0
- package/src/config/model-resolver.ts +57 -9
- package/src/config/settings-schema.ts +38 -10
- package/src/config/settings.ts +1 -4
- package/src/export/html/template.css +43 -13
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.html +1 -0
- package/src/export/html/template.js +107 -0
- package/src/extensibility/extensions/types.ts +31 -8
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +44 -44
- package/src/mcp/oauth-discovery.ts +1 -1
- package/src/modes/acp/acp-agent.ts +957 -0
- package/src/modes/acp/acp-event-mapper.ts +531 -0
- package/src/modes/acp/acp-mode.ts +13 -0
- package/src/modes/acp/index.ts +2 -0
- package/src/modes/components/agent-dashboard.ts +5 -4
- package/src/modes/components/custom-editor.ts +53 -51
- package/src/modes/components/extensions/extension-dashboard.ts +2 -1
- package/src/modes/components/history-search.ts +2 -1
- package/src/modes/components/hook-editor.ts +2 -1
- package/src/modes/components/hook-input.ts +8 -7
- package/src/modes/components/hook-selector.ts +15 -10
- package/src/modes/components/keybinding-hints.ts +9 -9
- package/src/modes/components/login-dialog.ts +3 -3
- package/src/modes/components/mcp-add-wizard.ts +2 -1
- package/src/modes/components/model-selector.ts +14 -3
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/session-selector.ts +2 -1
- package/src/modes/components/settings-selector.ts +2 -1
- package/src/modes/components/status-line-segment-editor.ts +2 -1
- package/src/modes/components/tree-selector.ts +3 -2
- package/src/modes/components/user-message-selector.ts +3 -8
- package/src/modes/components/user-message.ts +16 -0
- package/src/modes/controllers/extension-ui-controller.ts +89 -4
- package/src/modes/controllers/input-controller.ts +48 -29
- package/src/modes/controllers/mcp-command-controller.ts +1 -1
- package/src/modes/index.ts +1 -0
- package/src/modes/interactive-mode.ts +17 -5
- package/src/modes/print-mode.ts +1 -1
- package/src/modes/prompt-action-autocomplete.ts +7 -7
- package/src/modes/rpc/rpc-mode.ts +7 -2
- package/src/modes/rpc/rpc-types.ts +1 -0
- package/src/modes/theme/theme.ts +53 -44
- package/src/modes/types.ts +9 -2
- package/src/modes/utils/hotkeys-markdown.ts +20 -20
- package/src/modes/utils/keybinding-matchers.ts +21 -0
- package/src/modes/utils/ui-helpers.ts +1 -1
- package/src/patch/hashline.ts +139 -127
- package/src/patch/index.ts +77 -59
- package/src/patch/shared.ts +19 -11
- package/src/prompts/tools/hashline.md +43 -116
- package/src/sdk.ts +34 -17
- package/src/session/agent-session.ts +436 -86
- package/src/session/messages.ts +23 -0
- package/src/session/session-manager.ts +97 -31
- package/src/tools/ask.ts +56 -30
- package/src/tools/bash-interceptor.ts +1 -39
- package/src/tools/bash-skill-urls.ts +1 -1
- package/src/tools/browser.ts +1 -1
- package/src/tools/gemini-image.ts +1 -1
- package/src/tools/resolve.ts +1 -1
- package/src/utils/child-process.ts +88 -0
- package/src/utils/image-input.ts +11 -1
- package/src/web/search/providers/codex.ts +10 -3
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { matchesKey, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
|
|
2
|
+
import type { Theme } from "../modes/theme/theme";
|
|
3
|
+
import { replaceTabs } from "../tools/render-utils";
|
|
4
|
+
import { formatElapsed, formatNum, isBetter } from "./helpers";
|
|
5
|
+
import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state";
|
|
6
|
+
import type { AutoresearchRuntime, DashboardController, ExperimentResult, ExperimentState } from "./types";
|
|
7
|
+
|
|
8
|
+
export function createDashboardController(): DashboardController {
|
|
9
|
+
let overlayTui: { requestRender(): void } | null = null;
|
|
10
|
+
let spinnerTimer: NodeJS.Timeout | undefined;
|
|
11
|
+
let spinnerFrame = 0;
|
|
12
|
+
|
|
13
|
+
const requestRender = (): void => {
|
|
14
|
+
overlayTui?.requestRender();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const clear = (): void => {
|
|
18
|
+
overlayTui = null;
|
|
19
|
+
if (spinnerTimer) {
|
|
20
|
+
clearInterval(spinnerTimer);
|
|
21
|
+
spinnerTimer = undefined;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
clear(ctx): void {
|
|
27
|
+
clear();
|
|
28
|
+
if (ctx.hasUI) {
|
|
29
|
+
ctx.ui.setWidget("autoresearch", undefined);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
requestRender,
|
|
33
|
+
updateWidget(ctx, runtime): void {
|
|
34
|
+
if (!ctx.hasUI) return;
|
|
35
|
+
const state = runtime.state;
|
|
36
|
+
if (!shouldShowDashboard(runtime, state)) {
|
|
37
|
+
ctx.ui.setWidget("autoresearch", undefined);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ctx.ui.setWidget("autoresearch", (_tui, theme) => {
|
|
42
|
+
if (state.results.length === 0 && runtime.runningExperiment) {
|
|
43
|
+
return new Text(renderRunningOnly(runtime, state, theme), 0, 0);
|
|
44
|
+
}
|
|
45
|
+
if (runtime.dashboardExpanded) {
|
|
46
|
+
const width = process.stdout.columns ?? 120;
|
|
47
|
+
const lines = [
|
|
48
|
+
renderExpandedHeader(runtime, width, theme),
|
|
49
|
+
...renderDashboardLines(runtime, width, theme, 8),
|
|
50
|
+
];
|
|
51
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
52
|
+
}
|
|
53
|
+
return new Text(renderCollapsedLine(runtime, state, theme), 0, 0);
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
async showOverlay(ctx, runtime): Promise<void> {
|
|
57
|
+
if (!ctx.hasUI || !shouldShowDashboard(runtime, runtime.state)) return;
|
|
58
|
+
await ctx.ui.custom<void>(
|
|
59
|
+
(tui, theme, _keybindings, done) => {
|
|
60
|
+
overlayTui = tui;
|
|
61
|
+
if (!spinnerTimer) {
|
|
62
|
+
spinnerTimer = setInterval(() => {
|
|
63
|
+
spinnerFrame += 1;
|
|
64
|
+
requestRender();
|
|
65
|
+
}, 80);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let scrollOffset = 0;
|
|
69
|
+
return {
|
|
70
|
+
render(width: number): string[] {
|
|
71
|
+
const terminalRows = process.stdout.rows ?? 40;
|
|
72
|
+
const header = renderExpandedHeader(runtime, width, theme);
|
|
73
|
+
const body = renderDashboardLines(runtime, width, theme, 0);
|
|
74
|
+
if (runtime.runningExperiment) {
|
|
75
|
+
body.push(renderOverlayRunningLine(runtime, theme, width, spinnerFrame));
|
|
76
|
+
}
|
|
77
|
+
const viewportRows = Math.max(4, terminalRows - 4);
|
|
78
|
+
const maxScroll = Math.max(0, body.length - viewportRows);
|
|
79
|
+
if (scrollOffset > maxScroll) scrollOffset = maxScroll;
|
|
80
|
+
const visible = body.slice(scrollOffset, scrollOffset + viewportRows);
|
|
81
|
+
const footer = renderOverlayFooter(width, scrollOffset, viewportRows, body.length, theme);
|
|
82
|
+
return [
|
|
83
|
+
header,
|
|
84
|
+
...visible,
|
|
85
|
+
...Array.from({ length: Math.max(0, viewportRows - visible.length) }, () => ""),
|
|
86
|
+
footer,
|
|
87
|
+
];
|
|
88
|
+
},
|
|
89
|
+
handleInput(data: string): void {
|
|
90
|
+
const totalRows =
|
|
91
|
+
renderDashboardLines(runtime, process.stdout.columns ?? 120, theme, 0).length +
|
|
92
|
+
(runtime.runningExperiment ? 1 : 0);
|
|
93
|
+
const viewportRows = Math.max(4, (process.stdout.rows ?? 40) - 4);
|
|
94
|
+
const maxScroll = Math.max(0, totalRows - viewportRows);
|
|
95
|
+
if (matchesKey(data, "escape") || matchesKey(data, "esc") || data === "q") {
|
|
96
|
+
done(undefined);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
100
|
+
scrollOffset = Math.max(0, scrollOffset - 1);
|
|
101
|
+
} else if (matchesKey(data, "down") || data === "j") {
|
|
102
|
+
scrollOffset = Math.min(maxScroll, scrollOffset + 1);
|
|
103
|
+
} else if (matchesKey(data, "pageUp")) {
|
|
104
|
+
scrollOffset = Math.max(0, scrollOffset - viewportRows);
|
|
105
|
+
} else if (matchesKey(data, "pageDown")) {
|
|
106
|
+
scrollOffset = Math.min(maxScroll, scrollOffset + viewportRows);
|
|
107
|
+
} else if (data === "g") {
|
|
108
|
+
scrollOffset = 0;
|
|
109
|
+
} else if (data === "G") {
|
|
110
|
+
scrollOffset = maxScroll;
|
|
111
|
+
}
|
|
112
|
+
tui.requestRender();
|
|
113
|
+
},
|
|
114
|
+
invalidate(): void {},
|
|
115
|
+
dispose(): void {
|
|
116
|
+
clear();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
{ overlay: true },
|
|
121
|
+
);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderRunningOnly(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
|
|
127
|
+
const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", " running...")];
|
|
128
|
+
if (state.name) {
|
|
129
|
+
parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
|
|
130
|
+
}
|
|
131
|
+
if (runtime.runningExperiment) {
|
|
132
|
+
parts.push(theme.fg("dim", ` | ${replaceTabs(runtime.runningExperiment.command)}`));
|
|
133
|
+
}
|
|
134
|
+
return parts.join("");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function shouldShowDashboard(runtime: AutoresearchRuntime, state: ExperimentState): boolean {
|
|
138
|
+
return (
|
|
139
|
+
runtime.autoresearchMode ||
|
|
140
|
+
state.results.length > 0 ||
|
|
141
|
+
runtime.runningExperiment !== null ||
|
|
142
|
+
runtime.lastRunSummary !== null
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderExpandedHeader(runtime: AutoresearchRuntime, width: number, theme: Theme): string {
|
|
147
|
+
const state = runtime.state;
|
|
148
|
+
const status = renderModeStatus(runtime, state);
|
|
149
|
+
const label = state.name ? ` autoresearch: ${replaceTabs(state.name)} ` : " autoresearch ";
|
|
150
|
+
const hint = theme.fg("dim", ` ctrl+x collapse ctrl+shift+x overlay${status ? ` ${status}` : ""} `);
|
|
151
|
+
const fillWidth = Math.max(0, width - visibleWidth(label) - visibleWidth(hint));
|
|
152
|
+
return truncateToWidth(theme.fg("accent", label) + theme.fg("borderMuted", "-".repeat(fillWidth)) + hint, width);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string {
|
|
156
|
+
if (runtime.lastRunSummary) {
|
|
157
|
+
const parts = [
|
|
158
|
+
theme.fg("accent", "autoresearch"),
|
|
159
|
+
theme.fg("warning", ` pending run #${runtime.lastRunSummary.runNumber}`),
|
|
160
|
+
theme.fg("dim", runtime.lastRunSummary.passed ? " pass" : " fail"),
|
|
161
|
+
];
|
|
162
|
+
if (runtime.lastRunSummary.parsedPrimary !== null) {
|
|
163
|
+
parts.push(
|
|
164
|
+
theme.fg(
|
|
165
|
+
"muted",
|
|
166
|
+
` | ${state.metricName}=${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}`,
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
parts.push(theme.fg("warning", " | log_experiment required"));
|
|
171
|
+
if (!runtime.autoresearchMode) {
|
|
172
|
+
parts.push(theme.fg("dim", " | mode off"));
|
|
173
|
+
}
|
|
174
|
+
return parts.join("");
|
|
175
|
+
}
|
|
176
|
+
if (state.results.length === 0) {
|
|
177
|
+
const modeStatus = runtime.autoresearchMode ? "baseline pending" : "mode off";
|
|
178
|
+
const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", ` ${modeStatus}`)];
|
|
179
|
+
if (state.name) {
|
|
180
|
+
parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`));
|
|
181
|
+
}
|
|
182
|
+
if (runtime.autoresearchMode) {
|
|
183
|
+
parts.push(theme.fg("dim", " | run the baseline"));
|
|
184
|
+
}
|
|
185
|
+
return parts.join("");
|
|
186
|
+
}
|
|
187
|
+
const current = currentResults(state.results, state.currentSegment);
|
|
188
|
+
const kept = current.filter(result => result.status === "keep").length;
|
|
189
|
+
const crashed = current.filter(result => result.status === "crash").length;
|
|
190
|
+
const checksFailed = current.filter(result => result.status === "checks_failed").length;
|
|
191
|
+
const best = findBestResult(state);
|
|
192
|
+
const archivedRuns = Math.max(0, state.results.length - current.length);
|
|
193
|
+
const parts = [
|
|
194
|
+
theme.fg("accent", "autoresearch"),
|
|
195
|
+
theme.fg("muted", ` ${current.length} runs`),
|
|
196
|
+
theme.fg("success", ` ${kept} kept`),
|
|
197
|
+
];
|
|
198
|
+
if (archivedRuns > 0) parts.push(theme.fg("dim", ` +${archivedRuns} archived`));
|
|
199
|
+
if (crashed > 0) parts.push(theme.fg("error", ` ${crashed} crash`));
|
|
200
|
+
if (checksFailed > 0) parts.push(theme.fg("error", ` ${checksFailed} checks_failed`));
|
|
201
|
+
parts.push(theme.fg("dim", " | "));
|
|
202
|
+
if (best && state.bestMetric !== null && best.result.metric !== state.bestMetric) {
|
|
203
|
+
parts.push(theme.fg("warning", `best ${formatNum(best.result.metric, state.metricUnit)}`));
|
|
204
|
+
parts.push(theme.fg("dim", ` baseline ${formatNum(state.bestMetric, state.metricUnit)}`));
|
|
205
|
+
} else if (state.bestMetric !== null) {
|
|
206
|
+
parts.push(theme.fg("warning", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`));
|
|
207
|
+
} else {
|
|
208
|
+
parts.push(theme.fg("warning", `no kept runs yet`));
|
|
209
|
+
}
|
|
210
|
+
if (state.confidence !== null) {
|
|
211
|
+
const confidenceColor = state.confidence >= 2 ? "success" : state.confidence >= 1 ? "warning" : "error";
|
|
212
|
+
parts.push(theme.fg("dim", " | "));
|
|
213
|
+
parts.push(theme.fg(confidenceColor, `conf ${state.confidence.toFixed(1)}x`));
|
|
214
|
+
}
|
|
215
|
+
if (runtime.runningExperiment) {
|
|
216
|
+
parts.push(theme.fg("dim", ` | running ${formatElapsed(Date.now() - runtime.runningExperiment.startedAt)}`));
|
|
217
|
+
} else if (!runtime.autoresearchMode) {
|
|
218
|
+
parts.push(theme.fg("dim", ` | ${renderModeStatus(runtime, state)}`));
|
|
219
|
+
}
|
|
220
|
+
parts.push(theme.fg("dim", " | ctrl+x expand"));
|
|
221
|
+
return parts.join("");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function renderDashboardLines(
|
|
225
|
+
runtime: AutoresearchRuntime,
|
|
226
|
+
width: number,
|
|
227
|
+
theme: Theme,
|
|
228
|
+
maxRows: number,
|
|
229
|
+
): string[] {
|
|
230
|
+
const state = runtime.state;
|
|
231
|
+
if (state.results.length === 0) {
|
|
232
|
+
if (runtime.lastRunSummary) {
|
|
233
|
+
const lines = [
|
|
234
|
+
truncateToWidth(`Pending run: #${runtime.lastRunSummary.runNumber}`, width),
|
|
235
|
+
truncateToWidth(
|
|
236
|
+
`Result: ${runtime.lastRunSummary.passed ? "passed" : "failed"}${runtime.lastRunSummary.parsedPrimary !== null ? ` ${state.metricName} ${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}` : ""}`,
|
|
237
|
+
width,
|
|
238
|
+
),
|
|
239
|
+
truncateToWidth("Next action: finish log_experiment before starting another run.", width),
|
|
240
|
+
];
|
|
241
|
+
if (!runtime.autoresearchMode) {
|
|
242
|
+
lines.push(truncateToWidth("Mode: off", width));
|
|
243
|
+
}
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
if (runtime.autoresearchMode) {
|
|
247
|
+
return [
|
|
248
|
+
truncateToWidth("Current segment: 0 runs", width),
|
|
249
|
+
truncateToWidth("Baseline: pending", width),
|
|
250
|
+
truncateToWidth("Next action: run and log the baseline experiment.", width),
|
|
251
|
+
];
|
|
252
|
+
}
|
|
253
|
+
return [theme.fg("dim", "No experiments logged yet.")];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const current = currentResults(state.results, state.currentSegment);
|
|
257
|
+
const kept = current.filter(result => result.status === "keep").length;
|
|
258
|
+
const discarded = current.filter(result => result.status === "discard").length;
|
|
259
|
+
const crashed = current.filter(result => result.status === "crash").length;
|
|
260
|
+
const checksFailed = current.filter(result => result.status === "checks_failed").length;
|
|
261
|
+
const baseline = findBaselineMetric(state.results, state.currentSegment);
|
|
262
|
+
const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment);
|
|
263
|
+
const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics);
|
|
264
|
+
const best = findBestResult(state);
|
|
265
|
+
const lines = [
|
|
266
|
+
truncateToWidth(
|
|
267
|
+
`Current segment: ${current.length} runs ${kept} kept ${discarded} discarded ${crashed} crashed ${checksFailed} checks_failed`,
|
|
268
|
+
width,
|
|
269
|
+
),
|
|
270
|
+
truncateToWidth(
|
|
271
|
+
`Baseline: ${formatNum(baseline, state.metricUnit)}${baselineRunNumber ? ` (#${baselineRunNumber})` : ""}`,
|
|
272
|
+
width,
|
|
273
|
+
),
|
|
274
|
+
];
|
|
275
|
+
if (state.results.length > current.length) {
|
|
276
|
+
lines.push(
|
|
277
|
+
truncateToWidth(`Archived from earlier segments: ${state.results.length - current.length} runs`, width),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (runtime.lastRunSummary) {
|
|
281
|
+
lines.push(
|
|
282
|
+
truncateToWidth(
|
|
283
|
+
`Pending run: #${runtime.lastRunSummary.runNumber} (${runtime.lastRunSummary.passed ? "passed" : "failed"}) — log_experiment required`,
|
|
284
|
+
width,
|
|
285
|
+
),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
if (!runtime.autoresearchMode) {
|
|
289
|
+
lines.push(truncateToWidth(`Mode: ${renderModeStatus(runtime, state)}`, width));
|
|
290
|
+
}
|
|
291
|
+
if (best) {
|
|
292
|
+
const bestRunNumber = best.result.runNumber ?? best.index + 1;
|
|
293
|
+
let progress = `Best: ${formatNum(best.result.metric, state.metricUnit)} (#${bestRunNumber})`;
|
|
294
|
+
if (baseline !== null && baseline !== 0 && best.result.metric !== baseline) {
|
|
295
|
+
const delta = ((best.result.metric - baseline) / baseline) * 100;
|
|
296
|
+
const sign = delta > 0 ? "+" : "";
|
|
297
|
+
progress += ` ${sign}${delta.toFixed(1)}%`;
|
|
298
|
+
}
|
|
299
|
+
if (state.confidence !== null) {
|
|
300
|
+
progress += ` conf ${state.confidence.toFixed(1)}x`;
|
|
301
|
+
}
|
|
302
|
+
lines.push(truncateToWidth(progress, width));
|
|
303
|
+
if (state.secondaryMetrics.length > 0) {
|
|
304
|
+
const details = state.secondaryMetrics
|
|
305
|
+
.map(metric =>
|
|
306
|
+
renderSecondarySummary(
|
|
307
|
+
metric.name,
|
|
308
|
+
best.result.metrics[metric.name],
|
|
309
|
+
baselineSecondary[metric.name],
|
|
310
|
+
metric.unit,
|
|
311
|
+
),
|
|
312
|
+
)
|
|
313
|
+
.filter((value): value is string => Boolean(value));
|
|
314
|
+
if (details.length > 0) {
|
|
315
|
+
lines.push(truncateToWidth(`Secondary: ${details.join(" ")}`, width));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
lines.push("");
|
|
320
|
+
lines.push(renderTableHeader(state, width, theme));
|
|
321
|
+
lines.push(theme.fg("borderMuted", "-".repeat(Math.max(0, width - 1))));
|
|
322
|
+
|
|
323
|
+
const visible = maxRows > 0 ? current.slice(-maxRows) : current;
|
|
324
|
+
if (visible.length < current.length) {
|
|
325
|
+
lines.push(theme.fg("dim", `... ${current.length - visible.length} earlier runs hidden ...`));
|
|
326
|
+
}
|
|
327
|
+
for (const result of visible) {
|
|
328
|
+
lines.push(renderResultRow(result, state, baselineSecondary, width, theme));
|
|
329
|
+
}
|
|
330
|
+
return lines;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderTableHeader(state: ExperimentState, width: number, theme: Theme): string {
|
|
334
|
+
const secondaryHeader = state.secondaryMetrics.map(metric => truncateToWidth(metric.name, 10)).join(" ");
|
|
335
|
+
return truncateToWidth(
|
|
336
|
+
`${theme.fg("muted", "#".padEnd(4))}${theme.fg("muted", "commit".padEnd(10))}${theme.fg("warning", state.metricName.padEnd(12))}${secondaryHeader ? `${theme.fg("muted", secondaryHeader)} ` : ""}${theme.fg("muted", "status".padEnd(14))}${theme.fg("muted", "description")}`,
|
|
337
|
+
width,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function renderResultRow(
|
|
342
|
+
result: ExperimentResult,
|
|
343
|
+
state: ExperimentState,
|
|
344
|
+
baselineSecondary: { [key: string]: number },
|
|
345
|
+
width: number,
|
|
346
|
+
theme: Theme,
|
|
347
|
+
): string {
|
|
348
|
+
const runNumber = result.runNumber ?? state.results.indexOf(result) + 1;
|
|
349
|
+
const secondary = state.secondaryMetrics
|
|
350
|
+
.map(metric =>
|
|
351
|
+
truncateToWidth(
|
|
352
|
+
renderSecondaryCell(result.metrics[metric.name], metric.unit, baselineSecondary[metric.name]),
|
|
353
|
+
10,
|
|
354
|
+
).padEnd(11),
|
|
355
|
+
)
|
|
356
|
+
.join("");
|
|
357
|
+
const statusColor = result.status === "keep" ? "success" : result.status === "discard" ? "warning" : "error";
|
|
358
|
+
const line =
|
|
359
|
+
`${theme.fg("dim", String(runNumber).padEnd(4))}` +
|
|
360
|
+
`${theme.fg("accent", (result.commit || "-").padEnd(10))}` +
|
|
361
|
+
`${theme.fg(statusColor, formatNum(result.metric, state.metricUnit).padEnd(12))}` +
|
|
362
|
+
`${secondary}` +
|
|
363
|
+
`${theme.fg(statusColor, result.status.padEnd(14))}` +
|
|
364
|
+
`${theme.fg("muted", replaceTabs(result.description))}`;
|
|
365
|
+
return truncateToWidth(line, width);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderSecondaryCell(value: number | undefined, unit: string, baseline: number | undefined): string {
|
|
369
|
+
if (value === undefined) return "-";
|
|
370
|
+
const formatted = formatNum(value, unit);
|
|
371
|
+
if (baseline === undefined || baseline === 0 || baseline === value) return formatted;
|
|
372
|
+
const delta = ((value - baseline) / baseline) * 100;
|
|
373
|
+
const sign = delta > 0 ? "+" : "";
|
|
374
|
+
return `${formatted} ${sign}${delta.toFixed(1)}%`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function renderSecondarySummary(
|
|
378
|
+
name: string,
|
|
379
|
+
value: number | undefined,
|
|
380
|
+
baseline: number | undefined,
|
|
381
|
+
unit: string,
|
|
382
|
+
): string | null {
|
|
383
|
+
if (value === undefined) return null;
|
|
384
|
+
if (baseline === undefined || baseline === 0 || baseline === value) {
|
|
385
|
+
return `${name} ${formatNum(value, unit)}`;
|
|
386
|
+
}
|
|
387
|
+
const delta = ((value - baseline) / baseline) * 100;
|
|
388
|
+
const sign = delta > 0 ? "+" : "";
|
|
389
|
+
return `${name} ${formatNum(value, unit)} ${sign}${delta.toFixed(1)}%`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function renderOverlayRunningLine(
|
|
393
|
+
runtime: AutoresearchRuntime,
|
|
394
|
+
theme: Theme,
|
|
395
|
+
width: number,
|
|
396
|
+
spinnerFrame: number,
|
|
397
|
+
): string {
|
|
398
|
+
const spinner = theme.spinnerFrames[spinnerFrame % theme.spinnerFrames.length] ?? "*";
|
|
399
|
+
return truncateToWidth(
|
|
400
|
+
theme.fg(
|
|
401
|
+
"warning",
|
|
402
|
+
`${spinner} running ${formatElapsed(Date.now() - (runtime.runningExperiment?.startedAt ?? Date.now()))} ${replaceTabs(
|
|
403
|
+
runtime.runningExperiment?.command ?? "",
|
|
404
|
+
)}`,
|
|
405
|
+
),
|
|
406
|
+
width,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function renderOverlayFooter(
|
|
411
|
+
width: number,
|
|
412
|
+
scrollOffset: number,
|
|
413
|
+
viewportRows: number,
|
|
414
|
+
totalRows: number,
|
|
415
|
+
theme: Theme,
|
|
416
|
+
): string {
|
|
417
|
+
const position =
|
|
418
|
+
totalRows > viewportRows
|
|
419
|
+
? ` ${scrollOffset + 1}-${Math.min(totalRows, scrollOffset + viewportRows)}/${totalRows}`
|
|
420
|
+
: "";
|
|
421
|
+
const hint = theme.fg("dim", ` up/down j/k pageup pagedown g G esc${position} `);
|
|
422
|
+
const fill = Math.max(0, width - visibleWidth(hint));
|
|
423
|
+
return theme.fg("borderMuted", "-".repeat(fill)) + hint;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function renderModeStatus(runtime: AutoresearchRuntime, state: ExperimentState): string {
|
|
427
|
+
if (runtime.autoresearchMode) {
|
|
428
|
+
return state.results.length === 0 ? "baseline pending" : "mode on";
|
|
429
|
+
}
|
|
430
|
+
const current = currentResults(state.results, state.currentSegment);
|
|
431
|
+
if (state.maxExperiments !== null && current.length >= state.maxExperiments) {
|
|
432
|
+
return "segment complete";
|
|
433
|
+
}
|
|
434
|
+
return "mode off";
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function findBestResult(state: ExperimentState): { index: number; result: ExperimentResult } | null {
|
|
438
|
+
let best: { index: number; result: ExperimentResult } | null = null;
|
|
439
|
+
for (let index = 0; index < state.results.length; index += 1) {
|
|
440
|
+
const result = state.results[index];
|
|
441
|
+
if (result.segment !== state.currentSegment || result.status !== "keep" || result.metric <= 0) continue;
|
|
442
|
+
if (!best || isBetter(result.metric, best.result.metric, state.bestDirection)) {
|
|
443
|
+
best = { index, result };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return best;
|
|
447
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "../extensibility/extensions";
|
|
2
|
+
import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpers";
|
|
3
|
+
|
|
4
|
+
const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/";
|
|
5
|
+
const BRANCH_NAME_MAX_LENGTH = 48;
|
|
6
|
+
|
|
7
|
+
export interface EnsureAutoresearchBranchFailure {
|
|
8
|
+
error: string;
|
|
9
|
+
ok: false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface EnsureAutoresearchBranchSuccess {
|
|
13
|
+
branchName: string;
|
|
14
|
+
created: boolean;
|
|
15
|
+
ok: true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess;
|
|
19
|
+
|
|
20
|
+
export async function getCurrentAutoresearchBranch(api: ExtensionAPI, workDir: string): Promise<string | null> {
|
|
21
|
+
const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 });
|
|
22
|
+
const currentBranch = currentBranchResult.stdout.trim();
|
|
23
|
+
return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureAutoresearchBranch(
|
|
27
|
+
api: ExtensionAPI,
|
|
28
|
+
workDir: string,
|
|
29
|
+
goal: string | null,
|
|
30
|
+
): Promise<EnsureAutoresearchBranchResult> {
|
|
31
|
+
const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 });
|
|
32
|
+
if (repoRootResult.code !== 0) {
|
|
33
|
+
return {
|
|
34
|
+
error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.",
|
|
35
|
+
ok: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const repoRoot = repoRootResult.stdout.trim() || workDir;
|
|
39
|
+
|
|
40
|
+
const dirtyPathsResult = await api.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
|
|
41
|
+
cwd: repoRoot,
|
|
42
|
+
timeout: 5_000,
|
|
43
|
+
});
|
|
44
|
+
if (dirtyPathsResult.code !== 0) {
|
|
45
|
+
return {
|
|
46
|
+
error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`,
|
|
47
|
+
ok: false,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const workDirPrefix = await readGitWorkDirPrefix(api, workDir);
|
|
52
|
+
const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsResult.stdout, workDirPrefix);
|
|
53
|
+
const currentBranch = await getCurrentAutoresearchBranch(api, workDir);
|
|
54
|
+
if (currentBranch) {
|
|
55
|
+
if (unsafeDirtyPaths.length > 0) {
|
|
56
|
+
return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
branchName: currentBranch,
|
|
60
|
+
created: false,
|
|
61
|
+
ok: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (unsafeDirtyPaths.length > 0) {
|
|
65
|
+
return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const branchName = await allocateBranchName(api, workDir, goal);
|
|
69
|
+
const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 });
|
|
70
|
+
if (checkoutResult.code !== 0) {
|
|
71
|
+
return {
|
|
72
|
+
error:
|
|
73
|
+
`Failed to create autoresearch branch ${branchName}: ` +
|
|
74
|
+
`${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`,
|
|
75
|
+
ok: false,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
branchName,
|
|
81
|
+
created: true,
|
|
82
|
+
ok: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseWorkDirDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
|
|
87
|
+
const relativePaths: string[] = [];
|
|
88
|
+
for (const dirtyPath of parseDirtyPaths(statusOutput)) {
|
|
89
|
+
const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
|
|
90
|
+
if (relativePath === null) continue;
|
|
91
|
+
relativePaths.push(relativePath);
|
|
92
|
+
}
|
|
93
|
+
return relativePaths;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPrefix: string): string | null {
|
|
97
|
+
const normalizedPath = normalizeStatusPath(repoRelativePath);
|
|
98
|
+
const normalizedPrefix = normalizeAutoresearchPath(workDirPrefix);
|
|
99
|
+
if (normalizedPrefix === "" || normalizedPrefix === ".") {
|
|
100
|
+
return normalizedPath;
|
|
101
|
+
}
|
|
102
|
+
if (normalizedPath === normalizedPrefix) {
|
|
103
|
+
return ".";
|
|
104
|
+
}
|
|
105
|
+
if (!normalizedPath.startsWith(`${normalizedPrefix}/`)) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return normalizeAutoresearchPath(normalizedPath.slice(normalizedPrefix.length + 1));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readGitWorkDirPrefix(api: ExtensionAPI, workDir: string): Promise<string> {
|
|
112
|
+
const prefixResult = await api.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 });
|
|
113
|
+
if (prefixResult.code !== 0) {
|
|
114
|
+
return "";
|
|
115
|
+
}
|
|
116
|
+
return prefixResult.stdout.trim();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseDirtyPaths(statusOutput: string): string[] {
|
|
120
|
+
if (statusOutput.includes("\0")) {
|
|
121
|
+
return parseDirtyPathsNul(statusOutput);
|
|
122
|
+
}
|
|
123
|
+
return parseDirtyPathsLines(statusOutput);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseDirtyPathsNul(statusOutput: string): string[] {
|
|
127
|
+
const unsafePaths = new Set<string>();
|
|
128
|
+
let index = 0;
|
|
129
|
+
while (index + 3 <= statusOutput.length) {
|
|
130
|
+
const statusToken = statusOutput.slice(index, index + 3);
|
|
131
|
+
index += 3;
|
|
132
|
+
const pathEnd = statusOutput.indexOf("\0", index);
|
|
133
|
+
if (pathEnd < 0) break;
|
|
134
|
+
const firstPath = statusOutput.slice(index, pathEnd);
|
|
135
|
+
index = pathEnd + 1;
|
|
136
|
+
addDirtyPath(unsafePaths, firstPath);
|
|
137
|
+
if (isRenameOrCopy(statusToken)) {
|
|
138
|
+
const secondPathEnd = statusOutput.indexOf("\0", index);
|
|
139
|
+
if (secondPathEnd < 0) break;
|
|
140
|
+
const secondPath = statusOutput.slice(index, secondPathEnd);
|
|
141
|
+
index = secondPathEnd + 1;
|
|
142
|
+
addDirtyPath(unsafePaths, secondPath);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return [...unsafePaths];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseDirtyPathsLines(statusOutput: string): string[] {
|
|
149
|
+
const unsafePaths = new Set<string>();
|
|
150
|
+
for (const line of statusOutput.split("\n")) {
|
|
151
|
+
const trimmedLine = line.trimEnd();
|
|
152
|
+
if (trimmedLine.length < 4) continue;
|
|
153
|
+
const rawPath = trimmedLine.slice(3).trim();
|
|
154
|
+
if (rawPath.length === 0) continue;
|
|
155
|
+
const renameParts = rawPath.split(" -> ");
|
|
156
|
+
for (const renamePart of renameParts) {
|
|
157
|
+
addDirtyPath(unsafePaths, renamePart);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return [...unsafePaths];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function normalizeStatusPath(path: string): string {
|
|
164
|
+
let normalized = path.trim();
|
|
165
|
+
if (normalized.startsWith('"') && normalized.endsWith('"')) {
|
|
166
|
+
normalized = normalized.slice(1, -1);
|
|
167
|
+
}
|
|
168
|
+
return normalizeAutoresearchPath(normalized);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: string | null): Promise<string> {
|
|
172
|
+
const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${currentDateStamp()}`;
|
|
173
|
+
let candidate = baseName;
|
|
174
|
+
let suffix = 2;
|
|
175
|
+
while (await branchExists(api, workDir, candidate)) {
|
|
176
|
+
candidate = `${baseName}-${suffix}`;
|
|
177
|
+
suffix += 1;
|
|
178
|
+
}
|
|
179
|
+
return candidate;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise<boolean> {
|
|
183
|
+
const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
|
|
184
|
+
cwd: workDir,
|
|
185
|
+
timeout: 5_000,
|
|
186
|
+
});
|
|
187
|
+
return result.code === 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function slugifyGoal(goal: string | null): string {
|
|
191
|
+
const normalized = (goal ?? "")
|
|
192
|
+
.toLowerCase()
|
|
193
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
194
|
+
.replace(/^-+|-+$/g, "");
|
|
195
|
+
const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, "");
|
|
196
|
+
return trimmed || "session";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function currentDateStamp(): string {
|
|
200
|
+
const now = new Date();
|
|
201
|
+
const year = String(now.getFullYear());
|
|
202
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
203
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
204
|
+
return `${year}${month}${day}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function mergeStdoutStderr(result: { stderr: string; stdout: string }): string {
|
|
208
|
+
return `${result.stdout}${result.stderr}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function addDirtyPath(paths: Set<string>, rawPath: string): void {
|
|
212
|
+
const normalizedPath = normalizeStatusPath(rawPath);
|
|
213
|
+
if (normalizedPath.length === 0) return;
|
|
214
|
+
paths.add(normalizedPath);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function buildUnsafeDirtyPathsFailure(unsafeDirtyPaths: string[]): EnsureAutoresearchBranchFailure {
|
|
218
|
+
const preview = unsafeDirtyPaths.slice(0, 5).join(", ");
|
|
219
|
+
const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : "";
|
|
220
|
+
return {
|
|
221
|
+
error:
|
|
222
|
+
"Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. " +
|
|
223
|
+
`Commit or stash these paths first: ${preview}${suffix}`,
|
|
224
|
+
ok: false,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isRenameOrCopy(statusToken: string): boolean {
|
|
229
|
+
const trimmed = statusToken.trim();
|
|
230
|
+
return trimmed.startsWith("R") || trimmed.startsWith("C");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function collectUnsafeDirtyPaths(statusOutput: string, workDirPrefix: string): string[] {
|
|
234
|
+
const unsafeDirtyPaths: string[] = [];
|
|
235
|
+
for (const dirtyPath of parseDirtyPaths(statusOutput)) {
|
|
236
|
+
const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix);
|
|
237
|
+
if (relativePath && isAutoresearchLocalStatePath(relativePath)) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
unsafeDirtyPaths.push(relativePath ?? normalizeStatusPath(dirtyPath));
|
|
241
|
+
}
|
|
242
|
+
return unsafeDirtyPaths;
|
|
243
|
+
}
|