@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.16
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 +48 -0
- package/dist/cli.js +3725 -4029
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +12 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +15 -0
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/session-picker.ts +17 -3
- package/src/commands/launch.ts +3 -3
- package/src/config/settings-schema.ts +13 -1
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/controllers/command-controller.ts +5 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +16 -0
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/todo.md +1 -1
- package/src/session/agent-session.ts +231 -50
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +20 -0
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/web/search/providers/exa.ts +85 -1
|
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* On-disk roots the eval helpers (`read`/`write
|
|
160
|
+
* On-disk roots the eval helpers (`read`/`write`) substitute for
|
|
161
161
|
* internal-URL schemes so e.g. `write("local://x.md")` lands where a later
|
|
162
162
|
* `read local://x.md` resolves — instead of a literal `local:/` directory under
|
|
163
163
|
* the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
|
|
@@ -169,6 +169,96 @@ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record<string,
|
|
|
169
169
|
return { local: resolveLocalRoot(options) };
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
const LOCAL_WRITE_NOTE = "Use write path local://<file> to persist large intermediate artifacts across turns.";
|
|
173
|
+
|
|
174
|
+
type ResolvedLocalTarget =
|
|
175
|
+
| { kind: "listing"; root: string }
|
|
176
|
+
| { kind: "directory"; path: string }
|
|
177
|
+
| { kind: "file"; path: string; size: number };
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve a local:// URL to its on-disk target with realpath + containment
|
|
181
|
+
* checks on the root, parent, and target so symlinks cannot escape the session
|
|
182
|
+
* local root. Does NOT read or decode file contents — callers decide how to
|
|
183
|
+
* consume the resolved path. Shared by {@link LocalProtocolHandler.resolve} and
|
|
184
|
+
* {@link resolveLocalUrlToFile}.
|
|
185
|
+
*/
|
|
186
|
+
async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise<ResolvedLocalTarget> {
|
|
187
|
+
const localRoot = path.resolve(resolveLocalRoot(opts));
|
|
188
|
+
await fs.mkdir(localRoot, { recursive: true });
|
|
189
|
+
|
|
190
|
+
let resolvedRoot: string;
|
|
191
|
+
try {
|
|
192
|
+
resolvedRoot = await fs.realpath(localRoot);
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (isEnoent(error)) {
|
|
195
|
+
throw new Error("Unable to initialize local:// root");
|
|
196
|
+
}
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const relativePath = extractRelativePath(url);
|
|
201
|
+
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
202
|
+
ensureWithinRoot(targetPath, resolvedRoot);
|
|
203
|
+
|
|
204
|
+
if (targetPath === resolvedRoot) {
|
|
205
|
+
return { kind: "listing", root: resolvedRoot };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parentDir = path.dirname(targetPath);
|
|
209
|
+
try {
|
|
210
|
+
const realParent = await fs.realpath(parentDir);
|
|
211
|
+
ensureWithinRoot(realParent, resolvedRoot);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (!isEnoent(error)) throw error;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let realTargetPath: string;
|
|
217
|
+
try {
|
|
218
|
+
realTargetPath = await fs.realpath(targetPath);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isEnoent(error)) {
|
|
221
|
+
throw new Error(`Local file not found: ${url.href}`);
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
227
|
+
|
|
228
|
+
const stat = await fs.stat(realTargetPath);
|
|
229
|
+
if (stat.isDirectory()) {
|
|
230
|
+
return { kind: "directory", path: realTargetPath };
|
|
231
|
+
}
|
|
232
|
+
if (!stat.isFile()) {
|
|
233
|
+
throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
|
|
234
|
+
}
|
|
235
|
+
return { kind: "file", path: realTargetPath, size: stat.size };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Resolve a local:// URL to a regular on-disk file, applying the same
|
|
240
|
+
* realpath + containment guarantees as {@link LocalProtocolHandler.resolve}
|
|
241
|
+
* but WITHOUT reading or UTF-8-decoding its contents. Returns null when there
|
|
242
|
+
* is no active session or when the URL targets the root listing or a directory;
|
|
243
|
+
* throws the handler's not-found and "escapes local root" errors for missing
|
|
244
|
+
* files and symlink escapes.
|
|
245
|
+
*
|
|
246
|
+
* Options are resolved via {@link LocalProtocolHandler.resolveOptions} so the
|
|
247
|
+
* caller-options → override → registry order matches router resolution exactly.
|
|
248
|
+
* The read tool uses this to detect and emit image files from their real path
|
|
249
|
+
* before the text-only resource contract would decode the binary into mojibake.
|
|
250
|
+
*/
|
|
251
|
+
export async function resolveLocalUrlToFile(
|
|
252
|
+
input: string | InternalUrl,
|
|
253
|
+
context?: ResolveContext,
|
|
254
|
+
): Promise<{ path: string; size: number } | null> {
|
|
255
|
+
const opts = LocalProtocolHandler.resolveOptions(context);
|
|
256
|
+
if (!opts) return null;
|
|
257
|
+
const url = typeof input === "string" ? parseLocalUrl(input) : input;
|
|
258
|
+
const resolved = await resolveLocalTarget(url, opts);
|
|
259
|
+
return resolved.kind === "file" ? { path: resolved.path, size: resolved.size } : null;
|
|
260
|
+
}
|
|
261
|
+
|
|
172
262
|
/**
|
|
173
263
|
* Protocol handler for local:// URLs.
|
|
174
264
|
*
|
|
@@ -238,65 +328,22 @@ export class LocalProtocolHandler implements ProtocolHandler {
|
|
|
238
328
|
throw new Error("No session - local:// unavailable");
|
|
239
329
|
}
|
|
240
330
|
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
let resolvedRoot: string;
|
|
245
|
-
try {
|
|
246
|
-
resolvedRoot = await fs.realpath(localRoot);
|
|
247
|
-
} catch (error) {
|
|
248
|
-
if (isEnoent(error)) {
|
|
249
|
-
throw new Error("Unable to initialize local:// root");
|
|
250
|
-
}
|
|
251
|
-
throw error;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const relativePath = extractRelativePath(url);
|
|
255
|
-
const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
|
|
256
|
-
ensureWithinRoot(targetPath, resolvedRoot);
|
|
257
|
-
|
|
258
|
-
if (targetPath === resolvedRoot) {
|
|
259
|
-
return buildListing(url, resolvedRoot);
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const parentDir = path.dirname(targetPath);
|
|
263
|
-
try {
|
|
264
|
-
const realParent = await fs.realpath(parentDir);
|
|
265
|
-
ensureWithinRoot(realParent, resolvedRoot);
|
|
266
|
-
} catch (error) {
|
|
267
|
-
if (!isEnoent(error)) throw error;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
let realTargetPath: string;
|
|
271
|
-
try {
|
|
272
|
-
realTargetPath = await fs.realpath(targetPath);
|
|
273
|
-
} catch (error) {
|
|
274
|
-
if (isEnoent(error)) {
|
|
275
|
-
throw new Error(`Local file not found: ${url.href}`);
|
|
276
|
-
}
|
|
277
|
-
throw error;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
ensureWithinRoot(realTargetPath, resolvedRoot);
|
|
281
|
-
|
|
282
|
-
const stat = await fs.stat(realTargetPath);
|
|
283
|
-
if (stat.isDirectory()) {
|
|
284
|
-
return buildDirectoryResource(url.href, realTargetPath, [
|
|
285
|
-
"Use write path local://<file> to persist large intermediate artifacts across turns.",
|
|
286
|
-
]);
|
|
331
|
+
const resolved = await resolveLocalTarget(url, opts);
|
|
332
|
+
if (resolved.kind === "listing") {
|
|
333
|
+
return buildListing(url, resolved.root);
|
|
287
334
|
}
|
|
288
|
-
if (
|
|
289
|
-
|
|
335
|
+
if (resolved.kind === "directory") {
|
|
336
|
+
return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
|
|
290
337
|
}
|
|
291
338
|
|
|
292
|
-
const content = await Bun.file(
|
|
339
|
+
const content = await Bun.file(resolved.path).text();
|
|
293
340
|
return {
|
|
294
341
|
url: url.href,
|
|
295
342
|
content,
|
|
296
|
-
contentType: getContentType(
|
|
343
|
+
contentType: getContentType(resolved.path),
|
|
297
344
|
size: Buffer.byteLength(content, "utf-8"),
|
|
298
|
-
sourcePath:
|
|
299
|
-
notes: [
|
|
345
|
+
sourcePath: resolved.path,
|
|
346
|
+
notes: [LOCAL_WRITE_NOTE],
|
|
300
347
|
};
|
|
301
348
|
}
|
|
302
349
|
|
package/src/main.ts
CHANGED
|
@@ -945,6 +945,7 @@ async function buildSessionOptions(
|
|
|
945
945
|
interface RunRootCommandDependencies {
|
|
946
946
|
createAgentSession?: typeof createAgentSession;
|
|
947
947
|
discoverAuthStorage?: typeof discoverAuthStorage;
|
|
948
|
+
selectSession?: typeof selectSession;
|
|
948
949
|
runAcpMode?: RunAcpMode;
|
|
949
950
|
settings?: Settings;
|
|
950
951
|
forceSetupWizard?: boolean;
|
|
@@ -1131,7 +1132,8 @@ export async function runRootCommand(
|
|
|
1131
1132
|
// (see issue #1668).
|
|
1132
1133
|
if (typeof parsedArgs.resume === "string" && !sessionManager) {
|
|
1133
1134
|
writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
|
|
1134
|
-
|
|
1135
|
+
stopStartupWatchdog();
|
|
1136
|
+
process.exit(0);
|
|
1135
1137
|
}
|
|
1136
1138
|
|
|
1137
1139
|
// Handle --resume (no value): show session picker
|
|
@@ -1147,17 +1149,26 @@ export async function runRootCommand(
|
|
|
1147
1149
|
preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
|
|
1148
1150
|
if (preloadedAllSessions.length === 0) {
|
|
1149
1151
|
writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
|
|
1150
|
-
|
|
1152
|
+
stopStartupWatchdog();
|
|
1153
|
+
process.exit(0);
|
|
1151
1154
|
}
|
|
1152
1155
|
}
|
|
1153
1156
|
pauseStartupWatchdog();
|
|
1154
|
-
const selected = await logger.time("selectSession", selectSession, folderSessions, {
|
|
1157
|
+
const selected = await logger.time("selectSession", deps.selectSession ?? selectSession, folderSessions, {
|
|
1155
1158
|
allSessions: preloadedAllSessions,
|
|
1156
1159
|
});
|
|
1157
1160
|
resumeStartupWatchdog();
|
|
1158
1161
|
if (!selected) {
|
|
1159
1162
|
writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
|
|
1160
|
-
|
|
1163
|
+
// Quit instead of returning: startup already armed long-lived handles
|
|
1164
|
+
// (theme watcher + SIGWINCH/macOS appearance listeners via initTheme,
|
|
1165
|
+
// settings save timer, model registry) that keep the event loop alive,
|
|
1166
|
+
// so a bare return hangs the process after the picker leaves the alt
|
|
1167
|
+
// screen. No session was built here, so there is nothing to flush. The
|
|
1168
|
+
// in-session `/resume` picker (selector-controller.ts) takes a different
|
|
1169
|
+
// onCancel that just closes the overlay — only this startup path exits.
|
|
1170
|
+
stopStartupWatchdog();
|
|
1171
|
+
process.exit(0);
|
|
1161
1172
|
}
|
|
1162
1173
|
// Resuming a session from another project: switch the process into that
|
|
1163
1174
|
// project's directory and refresh cwd-derived caches before the session is
|
|
@@ -468,8 +468,13 @@ function buildEvalStartText(args: unknown): string | undefined {
|
|
|
468
468
|
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
469
469
|
return undefined;
|
|
470
470
|
}
|
|
471
|
-
const
|
|
472
|
-
|
|
471
|
+
const container = args as EvalCellContainer & EvalCellLike;
|
|
472
|
+
const cells = Array.isArray(container.cells)
|
|
473
|
+
? container.cells
|
|
474
|
+
: typeof container.code === "string"
|
|
475
|
+
? [container]
|
|
476
|
+
: [];
|
|
477
|
+
if (cells.length === 0) {
|
|
473
478
|
return undefined;
|
|
474
479
|
}
|
|
475
480
|
const lines: string[] = [];
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import {
|
|
12
12
|
Container,
|
|
13
13
|
Input,
|
|
14
|
+
matchesKey,
|
|
14
15
|
type SelectItem,
|
|
15
16
|
SelectList,
|
|
16
17
|
type SettingItem,
|
|
@@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border";
|
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
|
|
40
|
+
*
|
|
41
|
+
* Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the
|
|
42
|
+
* fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty),
|
|
43
|
+
* where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`.
|
|
44
|
+
* The literal fallbacks preserve legacy single/double-escape on terminals without it.
|
|
39
45
|
*/
|
|
40
46
|
export function handleInputOrEscape(
|
|
41
47
|
data: string,
|
|
42
48
|
input: { handleInput(data: string): void },
|
|
43
49
|
onCancel: () => void,
|
|
44
50
|
): void {
|
|
45
|
-
if (data === "\x1b" || data === "\x1b\x1b") {
|
|
51
|
+
if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) {
|
|
46
52
|
onCancel();
|
|
47
53
|
return;
|
|
48
54
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Input,
|
|
6
6
|
matchesKey,
|
|
7
7
|
padding,
|
|
8
|
+
parseSgrMouse,
|
|
8
9
|
replaceTabs,
|
|
9
10
|
ScrollView,
|
|
10
11
|
Spacer,
|
|
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
|
|
|
161
162
|
class SessionList implements Component {
|
|
162
163
|
#filteredSessions: SessionInfo[] = [];
|
|
163
164
|
#selectedIndex: number = 0;
|
|
165
|
+
// Maps a 0-based line within this list's own render to a filtered-session
|
|
166
|
+
// index, or undefined for chrome rows (search line, blanks, scrollbar gap).
|
|
167
|
+
// Rebuilt every render so the picker's mouse hit-testing tracks the live
|
|
168
|
+
// scroll window. Only consulted while the picker holds the alternate screen
|
|
169
|
+
// (where the overlay enables mouse tracking and paints from screen row 0).
|
|
170
|
+
#hitRows: (number | undefined)[] = [];
|
|
164
171
|
readonly #searchInput: Input;
|
|
165
172
|
onSelect?: (session: SessionInfo) => void;
|
|
166
173
|
onCancel?: () => void;
|
|
@@ -257,12 +264,32 @@ class SessionList implements Component {
|
|
|
257
264
|
}
|
|
258
265
|
}
|
|
259
266
|
|
|
267
|
+
/** Resolve a list-local rendered-line index to a filtered-session index. */
|
|
268
|
+
hitTestSession(line: number): number | undefined {
|
|
269
|
+
return this.#hitRows[line];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Wheel notch: move the selection one step (clamped, no wrap). */
|
|
273
|
+
handleWheel(delta: -1 | 1): void {
|
|
274
|
+
if (this.#filteredSessions.length === 0) return;
|
|
275
|
+
this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Mouse click: select the session under the pointer and resume it. */
|
|
279
|
+
selectAndConfirm(index: number): void {
|
|
280
|
+
const session = this.#filteredSessions[index];
|
|
281
|
+
if (!session) return;
|
|
282
|
+
this.#selectedIndex = index;
|
|
283
|
+
this.onSelect?.(session);
|
|
284
|
+
}
|
|
285
|
+
|
|
260
286
|
invalidate(): void {
|
|
261
287
|
// No cached state to invalidate currently
|
|
262
288
|
}
|
|
263
289
|
|
|
264
290
|
render(width: number): readonly string[] {
|
|
265
291
|
const lines: string[] = [];
|
|
292
|
+
this.#hitRows = [];
|
|
266
293
|
|
|
267
294
|
// Render search input
|
|
268
295
|
lines.push(...this.#searchInput.render(width));
|
|
@@ -311,9 +338,11 @@ class SessionList implements Component {
|
|
|
311
338
|
// Each session block is built into sessionLines, then wrapped by ScrollView
|
|
312
339
|
// so the right-edge scrollbar is proportional at the physical-line level.
|
|
313
340
|
const sessionLines: string[] = [];
|
|
341
|
+
const sessionRowIndex: number[] = [];
|
|
314
342
|
const overflow = this.#filteredSessions.length > maxVisible;
|
|
315
343
|
const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
|
|
316
344
|
for (let i = startIndex; i < endIndex; i++) {
|
|
345
|
+
const blockStart = sessionLines.length;
|
|
317
346
|
const session = this.#filteredSessions[i];
|
|
318
347
|
const isSelected = i === this.#selectedIndex;
|
|
319
348
|
|
|
@@ -363,6 +392,7 @@ class SessionList implements Component {
|
|
|
363
392
|
|
|
364
393
|
sessionLines.push(metadataLine);
|
|
365
394
|
sessionLines.push(""); // Blank line between sessions
|
|
395
|
+
for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
|
|
366
396
|
}
|
|
367
397
|
|
|
368
398
|
// Wrap the rendered window in a ScrollView for a proportional right-edge bar.
|
|
@@ -375,16 +405,10 @@ class SessionList implements Component {
|
|
|
375
405
|
theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
|
|
376
406
|
});
|
|
377
407
|
sv.setScrollOffset(Math.round(startIndex * linesPerItem));
|
|
378
|
-
lines.
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
lines.push(
|
|
382
|
-
lines.push(
|
|
383
|
-
theme.fg(
|
|
384
|
-
"muted",
|
|
385
|
-
` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
|
|
386
|
-
),
|
|
387
|
-
);
|
|
408
|
+
const sessionRegionStart = lines.length;
|
|
409
|
+
const svLines = sv.render(width);
|
|
410
|
+
for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
|
|
411
|
+
lines.push(...svLines);
|
|
388
412
|
|
|
389
413
|
return lines;
|
|
390
414
|
}
|
|
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
|
|
|
462
486
|
* Omitted only in tests; defaults to a conservative 24 rows.
|
|
463
487
|
*/
|
|
464
488
|
getTerminalRows?: () => number;
|
|
489
|
+
/**
|
|
490
|
+
* Fill the whole viewport and pin the footer (hint + bottom border) to the
|
|
491
|
+
* last rows, so the footer stops drifting as the list window changes height.
|
|
492
|
+
* Set by the standalone `--resume` picker (fullscreen alternate screen); the
|
|
493
|
+
* in-editor selector leaves it off and renders compactly.
|
|
494
|
+
*/
|
|
495
|
+
fillHeight?: boolean;
|
|
465
496
|
}
|
|
466
497
|
|
|
467
498
|
/**
|
|
@@ -470,6 +501,13 @@ export interface SessionSelectorOptions {
|
|
|
470
501
|
export class SessionSelectorComponent extends Container {
|
|
471
502
|
#sessionList: SessionList;
|
|
472
503
|
#confirmationDialog: HookSelectorComponent | null = null;
|
|
504
|
+
// Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
|
|
505
|
+
// frame. The delete dialog REPLACES the list in this slot rather than being
|
|
506
|
+
// appended below the picker chrome, so the picker is always
|
|
507
|
+
// `chrome + max(list, dialog) + chrome` and never overflows the viewport
|
|
508
|
+
// (issue #3283: an overflowing dialog frame committed the header into
|
|
509
|
+
// scrollback, stranding it above the viewport once the dialog closed).
|
|
510
|
+
#contentSlot: Container;
|
|
473
511
|
#messageContainer: Container;
|
|
474
512
|
#headerText: Text;
|
|
475
513
|
#onDelete?: (session: SessionInfo) => Promise<boolean>;
|
|
@@ -479,6 +517,18 @@ export class SessionSelectorComponent extends Container {
|
|
|
479
517
|
#globalSessions: SessionInfo[] | null = null;
|
|
480
518
|
#scope: "folder" | "all" = "folder";
|
|
481
519
|
#toggling = false;
|
|
520
|
+
// 0-based line where the session list begins within this component's own
|
|
521
|
+
// render, captured each frame. The fullscreen picker overlay paints from
|
|
522
|
+
// screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
|
|
523
|
+
// list. Only meaningful while the picker holds the alternate screen.
|
|
524
|
+
#listLineOffset = 0;
|
|
525
|
+
// 0-based line where the pinned footer begins; clicks at or below it never
|
|
526
|
+
// hit-test the list, so a footer click on a cramped (trimmed) frame can't
|
|
527
|
+
// resume a session scrolled off-screen.
|
|
528
|
+
#footerStart = 0;
|
|
529
|
+
readonly #getTerminalRows: () => number;
|
|
530
|
+
readonly #fillHeight: boolean;
|
|
531
|
+
readonly #bottomBorder = new DynamicBorder();
|
|
482
532
|
|
|
483
533
|
constructor(
|
|
484
534
|
sessions: SessionInfo[],
|
|
@@ -494,6 +544,8 @@ export class SessionSelectorComponent extends Container {
|
|
|
494
544
|
this.#loadAllSessions = options.loadAllSessions;
|
|
495
545
|
this.#folderSessions = sessions;
|
|
496
546
|
this.#globalSessions = options.allSessions ?? null;
|
|
547
|
+
this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
|
|
548
|
+
this.#fillHeight = options.fillHeight ?? false;
|
|
497
549
|
// Add header
|
|
498
550
|
this.addChild(new Spacer(1));
|
|
499
551
|
this.#headerText = new Text(this.#headerLabel(), 1, 0);
|
|
@@ -517,11 +569,9 @@ export class SessionSelectorComponent extends Container {
|
|
|
517
569
|
void this.#toggleScope();
|
|
518
570
|
};
|
|
519
571
|
}
|
|
520
|
-
this
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
this.addChild(new Spacer(1));
|
|
524
|
-
this.addChild(new DynamicBorder());
|
|
572
|
+
this.#contentSlot = new Container();
|
|
573
|
+
this.#contentSlot.addChild(this.#sessionList);
|
|
574
|
+
this.addChild(this.#contentSlot);
|
|
525
575
|
}
|
|
526
576
|
|
|
527
577
|
#headerLabel(): string {
|
|
@@ -582,6 +632,15 @@ export class SessionSelectorComponent extends Container {
|
|
|
582
632
|
|
|
583
633
|
#showDeleteConfirmation(session: SessionInfo): void {
|
|
584
634
|
const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
|
|
635
|
+
const closeDialog = () => {
|
|
636
|
+
this.#confirmationDialog = null;
|
|
637
|
+
// Restore the SessionList into the content slot so the picker is back
|
|
638
|
+
// to its normal layout on the very next render — the same frame the
|
|
639
|
+
// dialog disappears.
|
|
640
|
+
this.#contentSlot.clear();
|
|
641
|
+
this.#contentSlot.addChild(this.#sessionList);
|
|
642
|
+
this.#onRequestRender?.();
|
|
643
|
+
};
|
|
585
644
|
this.#confirmationDialog = new HookSelectorComponent(
|
|
586
645
|
`Delete session?\n${displayName}`,
|
|
587
646
|
["Yes", "No"],
|
|
@@ -597,25 +656,61 @@ export class SessionSelectorComponent extends Container {
|
|
|
597
656
|
this.#showError(err instanceof Error ? err.message : String(err));
|
|
598
657
|
}
|
|
599
658
|
}
|
|
600
|
-
|
|
601
|
-
this.removeChild(this.#confirmationDialog!);
|
|
602
|
-
this.#confirmationDialog = null;
|
|
603
|
-
// Request rerender
|
|
604
|
-
this.#onRequestRender?.();
|
|
605
|
-
},
|
|
606
|
-
() => {
|
|
607
|
-
// Cancel - close confirmation dialog
|
|
608
|
-
this.removeChild(this.#confirmationDialog!);
|
|
609
|
-
this.#confirmationDialog = null;
|
|
610
|
-
// Request rerender
|
|
611
|
-
this.#onRequestRender?.();
|
|
659
|
+
closeDialog();
|
|
612
660
|
},
|
|
661
|
+
closeDialog,
|
|
613
662
|
);
|
|
614
|
-
//
|
|
615
|
-
|
|
663
|
+
// Swap the SessionList out of the content slot and mount the dialog in its
|
|
664
|
+
// place: the dialog competes only with the SessionList's rendered budget,
|
|
665
|
+
// never the SessionList AND the picker chrome, so the picker frame stays
|
|
666
|
+
// inside the terminal viewport and the TUI never commits the header into
|
|
667
|
+
// scrollback (issue #3283).
|
|
668
|
+
this.#contentSlot.clear();
|
|
669
|
+
this.#contentSlot.addChild(this.#confirmationDialog);
|
|
670
|
+
this.#onRequestRender?.();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Concatenate the children's renders (like {@link Container}) while recording
|
|
675
|
+
* the line where the session list begins, so the fullscreen picker can hit-
|
|
676
|
+
* test mouse rows against the live list window. SessionList rebuilds its lines
|
|
677
|
+
* every frame, so Container's reference-memoization never applied here.
|
|
678
|
+
*
|
|
679
|
+
* In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
|
|
680
|
+
* to leave exactly enough room for the footer at the screen bottom, so the
|
|
681
|
+
* footer is always visible and never drifts as the list window resizes. The
|
|
682
|
+
* in-editor selector just appends the footer directly.
|
|
683
|
+
*/
|
|
684
|
+
render(width: number): readonly string[] {
|
|
685
|
+
const lines: string[] = [];
|
|
686
|
+
for (const child of this.children) {
|
|
687
|
+
const childLines = child.render(width);
|
|
688
|
+
if (child === this.#contentSlot) this.#listLineOffset = lines.length;
|
|
689
|
+
for (const line of childLines) lines.push(line);
|
|
690
|
+
}
|
|
691
|
+
const footer = this.#footerLines(width);
|
|
692
|
+
if (this.#fillHeight) {
|
|
693
|
+
const target = Math.max(0, this.#getTerminalRows() - footer.length);
|
|
694
|
+
if (lines.length > target) lines.length = target;
|
|
695
|
+
else for (let i = lines.length; i < target; i++) lines.push("");
|
|
696
|
+
}
|
|
697
|
+
this.#footerStart = lines.length;
|
|
698
|
+
for (const line of footer) lines.push(line);
|
|
699
|
+
return lines;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
|
|
703
|
+
#footerLines(width: number): string[] {
|
|
704
|
+
const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
|
|
705
|
+
const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
|
|
706
|
+
return ["", hint, "", ...this.#bottomBorder.render(width)];
|
|
616
707
|
}
|
|
617
708
|
|
|
618
709
|
handleInput(keyData: string): void {
|
|
710
|
+
if (keyData.startsWith("\x1b[<")) {
|
|
711
|
+
this.#handleMouse(keyData);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
619
714
|
if (this.#confirmationDialog) {
|
|
620
715
|
this.#confirmationDialog.handleInput(keyData);
|
|
621
716
|
} else {
|
|
@@ -623,6 +718,25 @@ export class SessionSelectorComponent extends Container {
|
|
|
623
718
|
}
|
|
624
719
|
}
|
|
625
720
|
|
|
721
|
+
/**
|
|
722
|
+
* SGR mouse reports, delivered only while the picker holds the alternate
|
|
723
|
+
* screen (the fullscreen overlay enables tracking and paints from screen row
|
|
724
|
+
* 0). Wheel scrolls the list; a left click resumes the session under the
|
|
725
|
+
* pointer. Mouse is inert while the delete-confirmation dialog is open.
|
|
726
|
+
*/
|
|
727
|
+
#handleMouse(data: string): void {
|
|
728
|
+
if (this.#confirmationDialog) return;
|
|
729
|
+
const event = parseSgrMouse(data);
|
|
730
|
+
if (!event) return;
|
|
731
|
+
if (event.wheel !== null) {
|
|
732
|
+
this.#sessionList.handleWheel(event.wheel);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (!event.leftClick || event.row >= this.#footerStart) return;
|
|
736
|
+
const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
|
|
737
|
+
if (index !== undefined) this.#sessionList.selectAndConfirm(index);
|
|
738
|
+
}
|
|
739
|
+
|
|
626
740
|
getSessionList(): SessionList {
|
|
627
741
|
return this.#sessionList;
|
|
628
742
|
}
|
|
@@ -1172,6 +1172,11 @@ export class CommandController {
|
|
|
1172
1172
|
}
|
|
1173
1173
|
|
|
1174
1174
|
async handleHandoffCommand(customInstructions?: string): Promise<void> {
|
|
1175
|
+
if (this.ctx.session.isStreaming) {
|
|
1176
|
+
this.ctx.showWarning("Wait for the current response to finish or abort it before handing off.");
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1175
1180
|
const entries = this.ctx.sessionManager.getEntries();
|
|
1176
1181
|
const messageCount = entries.filter(e => e.type === "message").length;
|
|
1177
1182
|
|
|
@@ -1008,6 +1008,12 @@ export async function runRpcMode(
|
|
|
1008
1008
|
}
|
|
1009
1009
|
|
|
1010
1010
|
case "handoff": {
|
|
1011
|
+
// Resetting the agent mid-stream lets the live turn keep emitting into a
|
|
1012
|
+
// session that handoff has already torn down. Refuse while a prompt is in
|
|
1013
|
+
// flight (mirrors the TUI /handoff guard).
|
|
1014
|
+
if (session.isStreaming) {
|
|
1015
|
+
return error(id, "handoff", "Cannot hand off while a response is in progress");
|
|
1016
|
+
}
|
|
1011
1017
|
const result = await session.handoff(command.customInstructions);
|
|
1012
1018
|
return success(id, "handoff", result ? { savedPath: result.savedPath } : null);
|
|
1013
1019
|
}
|
|
@@ -127,8 +127,13 @@ export function extractQuoteBlocks(text: string): QuoteBlock[] {
|
|
|
127
127
|
|
|
128
128
|
function extractEvalCode(args: unknown): { code: string; language: string } | undefined {
|
|
129
129
|
if (!args || typeof args !== "object") return undefined;
|
|
130
|
-
const
|
|
131
|
-
|
|
130
|
+
const argsObj = args as { cells?: unknown; code?: unknown };
|
|
131
|
+
const cells = Array.isArray(argsObj.cells)
|
|
132
|
+
? argsObj.cells
|
|
133
|
+
: typeof argsObj.code === "string"
|
|
134
|
+
? [argsObj]
|
|
135
|
+
: undefined;
|
|
136
|
+
if (!cells) return undefined;
|
|
132
137
|
|
|
133
138
|
const codeBlocks: string[] = [];
|
|
134
139
|
let language = "python";
|
|
@@ -109,9 +109,9 @@ You MUST use the specialized tool over its shell equivalent:
|
|
|
109
109
|
{{#has tools "lsp"}}- Code intelligence → `{{toolRefs.lsp}}`.{{/has}}
|
|
110
110
|
{{#has tools "search"}}- Regex search → `{{toolRefs.search}}`, not `grep`, `rg`, or `awk`.{{/has}}
|
|
111
111
|
{{#has tools "find"}}- Globbing → `{{toolRefs.find}}`, not `ls **/*.ext` or `fd`.{{/has}}
|
|
112
|
-
{{#has tools "eval"}}-
|
|
113
|
-
{{#has tools "bash"}}-
|
|
114
|
-
- Litmus:
|
|
112
|
+
{{#has tools "eval"}}- Default for any compute: `{{toolRefs.eval}}` cells. Bash is the EXCEPTION — only single binary calls or short fact-computing pipelines (`wc -l`, `sort | uniq -c`, `diff`, checksums). The moment a command grows a loop, conditional, heredoc, `-e`/`-c` script, `$(…)` nesting, or >2 pipe stages, it's a program → `{{toolRefs.eval}}`. NEVER write multiline or inline-script bash.{{/has}}
|
|
113
|
+
{{#has tools "bash"}}- `{{toolRefs.bash}}`: real binaries and short fact pipelines only. Commands shadowing the specialized tools above are blocked.{{/has}}
|
|
114
|
+
{{#has tools "bash"}}- Litmus: one external-CLI call or short pipeline returning a count, frequency, set difference, or checksum → bash.{{#has tools "eval"}} Needs control flow, state, or fights shell quoting → `{{toolRefs.eval}}`.{{/has}} Merely moves, pages, or trims bytes a tool can fetch → use the tool.{{/has}}
|
|
115
115
|
|
|
116
116
|
{{#has tools "report_tool_issue"}}
|
|
117
117
|
<critical>
|
|
@@ -11,16 +11,16 @@ Worth it when the task benefits from decomposition + parallel coverage, or from
|
|
|
11
11
|
</when>
|
|
12
12
|
|
|
13
13
|
<helpers>
|
|
14
|
-
State persists across
|
|
14
|
+
State persists across eval calls, so scout in one call and fan out in the next. Every eval call has:
|
|
15
15
|
|
|
16
|
-
- `agent(prompt, *,
|
|
16
|
+
- `agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False)` — run ONE subagent; returns its final text, or the validated object when `schema` (a JSON Schema dict) is given. With `schema` the subagent is forced to emit structured output that is validated for you — branch on the object, not on parsed prose. `agent` picks a discovered agent ("explore", "reviewer", "oracle", …); `label` names the artifact. Shared background goes in a `local://` file referenced from each prompt, not a parameter. Subagents are told their final text IS the return value, so they hand back raw data. `agent()` blocks until the subagent finishes; eval-spawned agents nest at most 3 deep. Pass `isolated=True` to run the spawn in a copy-on-write worktree so parallel `agent()` calls can edit overlapping files safely — strict opt-in, mirrors the `task` tool, defaults off regardless of `task.isolation.mode`; `isolated=True` while the setting is `"none"` errors out instead of silently downgrading. With isolation, `apply=False` keeps changes in the worktree, and `merge=False` forces patch mode even when the setting is `"branch"`. Captured root patch path, branch name, nested repo patches, and apply summary reach the workflow through `handle=True` — combine it with `apply=False` (or `apply=False, schema=…`) and read `node["patch_path"]`, `node["branch_name"]`, `node["nested_patches"]`, `node["changes_applied"]`, `node["isolation_summary"]` (JS: same keys camelCased) to recover artifacts.
|
|
17
17
|
- `parallel(thunks)` — run zero-arg callables concurrently through a bounded pool, preserving input order; returns once all finish. The pool is bounded by the session's `task` concurrency — don't hand-tune it; fan out as wide as the work divides. A thunk that raises propagates — wrap risky work in `try/except` inside the thunk to keep partial results. In a loop, bind each closure's value with a default arg (`lambda d=d: …`) or every thunk captures the last one.
|
|
18
18
|
- `pipeline(items, *stages)` — map items through `stages` left-to-right. There is a BARRIER between stages: ALL items clear stage N before stage N+1 begins. Each stage is a one-arg callable; stage 1 gets the original item, later stages get the previous result. Same pool width as `parallel()`.
|
|
19
19
|
- `completion(prompt, *, model="default", system=None, schema=None)` — oneshot, stateless model call (no tools, no history). Tiers: "smol", "default", "slow". Cheap classification/scoring inside a fan-out.
|
|
20
20
|
- `log(message)` — emit a progress line above the status tree. `phase(title)` — start a phase; the status lines that follow group under it.
|
|
21
21
|
- `budget` — `budget.total` (output-token ceiling, or `None` when none is set), `budget.spent()` (tokens spent this turn — main loop + eval subagents), `budget.remaining()` (`math.inf` when total is `None`), `budget.hard` (whether it's enforced). A ceiling is set by the user: `+Nk` in their message is advisory (you self-limit via `budget.remaining()`), `+Nk!` (or Goal Mode) is hard — `agent()` refuses to spawn once spent reaches it. Gate loops on `budget.total` first, since it's `None` when the user set no budget.
|
|
22
22
|
|
|
23
|
-
Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across
|
|
23
|
+
Everything runs INLINE and synchronously inside the eval call — no background mode, no resume, no separate progress app. Each eval call is one well-scoped fan-out; chain several across calls and turns for multi-phase work, reading each result before you decide the next phase.
|
|
24
24
|
</helpers>
|
|
25
25
|
|
|
26
26
|
<structure>
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
Runs bash in a shell session — terminal ops: git, bun, cargo, python.
|
|
2
2
|
|
|
3
|
+
# When to use bash — and when not to
|
|
4
|
+
|
|
5
|
+
Bash invokes **real binaries** with simple args. It is NOT a scripting surface.
|
|
6
|
+
|
|
7
|
+
Use bash ONLY for: a single binary call, or one short pipeline that COMPUTES a fact (`wc -l`, `sort | uniq -c`, `comm`, `diff`, a checksum, `git status`).
|
|
8
|
+
|
|
9
|
+
Anything below → `eval` cell, not bash:
|
|
10
|
+
- Inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists for that language
|
|
11
|
+
- Heredocs (`<<EOF`), `while`/`for`/`if`/`case` shell control flow
|
|
12
|
+
- `$(…)` command substitution nested inside another command
|
|
13
|
+
- Pipelines with more than two stages, or stages that need control flow or quote/JSON escaping
|
|
14
|
+
- Multiline commands, `&&`-chains mixing control flow
|
|
15
|
+
- Quote/JSON escaping that fights the shell
|
|
16
|
+
|
|
3
17
|
<instruction>
|
|
4
18
|
- `cwd` sets the working dir, not `cd dir && …`
|
|
5
19
|
- `env: { NAME: "…" }` for multiline / quote-heavy / untrusted values; reference `$NAME`
|
|
@@ -14,7 +28,9 @@ Runs bash in a shell session — terminal ops: git, bun, cargo, python.
|
|
|
14
28
|
</instruction>
|
|
15
29
|
|
|
16
30
|
<critical>
|
|
31
|
+
- Bash invokes real binaries with simple args; it is NOT a scripting surface. Loops, conditionals, heredocs, inline interpreter scripts (`-e`/`-c`/`--eval`) when an eval runtime exists, several piped stages, or quote/JSON escaping mean you're writing a program → use `eval` cells: restartable, stateful, and free of shell-quoting traps.
|
|
17
32
|
- NEVER shell out to search content or files: `grep/rg` → `search`.
|
|
33
|
+
- NEVER use `ls` or `find` to list or locate files — `ls` → `read` (a directory path lists entries), `find` → the `find` tool (globbing). This is non-negotiable, even for a single quick listing.
|
|
18
34
|
- Avoid head/tail/redirections: stderr already merged; long output auto-truncated, FULL capture kept at `artifact://<id>`.
|
|
19
35
|
</critical>
|
|
20
36
|
|