@oh-my-pi/pi-tui 18.8.7 → 18.8.8
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 +28 -64
- package/dist/types/chat/execution-shared.d.ts +6 -5
- package/dist/types/keybinding-matchers.d.ts +3 -0
- package/dist/types/overlays/annotation-overlay.d.ts +12 -0
- package/dist/types/overlays/annotation-types.d.ts +2 -0
- package/dist/types/overlays/hook-selector.d.ts +9 -4
- package/dist/types/overlays/model-browser.d.ts +2 -2
- package/dist/types/overlays/model-hub.d.ts +21 -4
- package/dist/types/overlays/session-observer-registry.d.ts +2 -0
- package/dist/types/overlays/usage-dashboard.d.ts +3 -1
- package/dist/types/status-line/types.d.ts +1 -1
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/tools/eval.d.ts +1 -1
- package/dist/types/tools/native-view.d.ts +47 -0
- package/dist/types/tools/renderer.d.ts +15 -2
- package/dist/types/tui.d.ts +20 -0
- package/dist/types/vim.d.ts +19 -5
- package/package.json +9 -9
- package/src/chat/execution-shared.ts +42 -25
- package/src/chat/tool-execution.ts +9 -2
- package/src/components/editor.ts +182 -5
- package/src/keybinding-matchers.ts +6 -2
- package/src/overlays/agent-hub.ts +7 -3
- package/src/overlays/annotation-overlay.ts +87 -6
- package/src/overlays/annotation-types.ts +2 -0
- package/src/overlays/hook-selector.ts +12 -5
- package/src/overlays/model-browser.ts +3 -3
- package/src/overlays/model-hub.ts +107 -27
- package/src/overlays/session-observer-registry.ts +10 -2
- package/src/overlays/usage-dashboard.ts +70 -23
- package/src/prompt/composer.ts +1 -0
- package/src/status-line/segments.ts +3 -0
- package/src/status-line/types.ts +1 -1
- package/src/theme/symbols.ts +5 -0
- package/src/tools/bash.ts +82 -31
- package/src/tools/eval.ts +149 -123
- package/src/tools/native-view.ts +102 -1
- package/src/tools/renderer.ts +15 -2
- package/src/tui.ts +68 -0
- package/src/vim.ts +333 -18
|
@@ -20,6 +20,7 @@ import { DEFAULT_TERMINAL_PREVIEW_LINES, expandKeyHint } from "../render/render-
|
|
|
20
20
|
import type { TspCardStatus, TspSpan, TspText, TspTone } from "@oh-my-pi/pi-wire";
|
|
21
21
|
import { card, node, span, text } from "../native/describe";
|
|
22
22
|
import type { NativeChild, NativeNode } from "../native/node";
|
|
23
|
+
import { runBox, runFoot, runInput, runOutput, type RunState } from "../tools/native-view";
|
|
23
24
|
|
|
24
25
|
/** Output rows shown while an execution is collapsed. */
|
|
25
26
|
export const PREVIEW_LINES = 20;
|
|
@@ -190,7 +191,7 @@ export interface ExecutionToolInput {
|
|
|
190
191
|
/** Tool name for the head icon (`bash`, `eval`). */
|
|
191
192
|
readonly name: string;
|
|
192
193
|
readonly title: string;
|
|
193
|
-
/** The command / cell source
|
|
194
|
+
/** The command / cell source: the run box's input and the head's Copy command. */
|
|
194
195
|
readonly command: string;
|
|
195
196
|
readonly lang: string;
|
|
196
197
|
/** Excluded from context (`!!`/`$$`). */
|
|
@@ -208,39 +209,55 @@ export interface ExecutionToolInput {
|
|
|
208
209
|
readonly artifactError?: OutputArtifactError;
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
const EXECUTION_RUN_STATE: Record<ExecutionStatus, RunState> = {
|
|
213
|
+
running: "running",
|
|
214
|
+
complete: "done",
|
|
215
|
+
cancelled: "cancelled",
|
|
216
|
+
error: "failed",
|
|
217
|
+
};
|
|
218
|
+
|
|
211
219
|
/**
|
|
212
|
-
* A user `!`/`$` run as the agent's `tool` frame (§6.6):
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
220
|
+
* A user `!`/`$` run as the agent's `tool` frame (§6.6): a head with a `you`
|
|
221
|
+
* badge (plus `not sent` when excluded from context) and the command for Copy
|
|
222
|
+
* command; the body is one run box — the source, the output as an `ansi` mini
|
|
223
|
+
* terminal following its tail, and the foot (state, time, truncation) — with
|
|
224
|
+
* any images below it. Timer, exit chip and cancelled note stay head data.
|
|
216
225
|
*/
|
|
217
226
|
export function describeExecutionTool(input: ExecutionToolInput): NativeNode {
|
|
218
227
|
const running = input.status === "running";
|
|
219
|
-
const
|
|
220
|
-
if (input.output) body.push(node("ansi", { text: input.output, follow: running }, undefined, "output"));
|
|
221
|
-
if (input.images) body.push(...input.images);
|
|
222
|
-
const notes: string[] = [];
|
|
223
|
-
if (input.truncation) notes.push(formatTruncationMetaNotice(input.truncation));
|
|
224
|
-
if (input.artifactError) notes.push(formatArtifactErrorNotice(input.artifactError));
|
|
225
|
-
if (notes.length > 0) {
|
|
226
|
-
body.push({
|
|
227
|
-
...text([span(notes.join(" · "), "muted")], { wrap: "word", role: "omp.tool.notice" }),
|
|
228
|
-
key: "foot",
|
|
229
|
-
});
|
|
230
|
-
}
|
|
228
|
+
const shell = input.name === "bash";
|
|
231
229
|
const ms = Math.max(0, Math.round((input.endedAt ?? performance.now()) - input.startedAt));
|
|
230
|
+
const state = EXECUTION_RUN_STATE[input.status];
|
|
231
|
+
const box = runBox(
|
|
232
|
+
[
|
|
233
|
+
runInput(input.command, {
|
|
234
|
+
role: shell ? "omp.tool.bash.command" : "omp.tool.eval.input",
|
|
235
|
+
lang: input.lang,
|
|
236
|
+
wrap: shell,
|
|
237
|
+
}),
|
|
238
|
+
input.output.length > 0 &&
|
|
239
|
+
runOutput(input.output, {
|
|
240
|
+
role: shell ? "omp.tool.bash.output" : "omp.tool.eval.output",
|
|
241
|
+
previewLines: DEFAULT_TERMINAL_PREVIEW_LINES,
|
|
242
|
+
}),
|
|
243
|
+
runFoot({
|
|
244
|
+
state,
|
|
245
|
+
exitCode: input.exitCode,
|
|
246
|
+
elapsedMs: ms,
|
|
247
|
+
meta: { truncation: input.truncation, artifactError: input.artifactError },
|
|
248
|
+
}),
|
|
249
|
+
],
|
|
250
|
+
{ key: "run", tone: state === "failed" ? "error" : undefined },
|
|
251
|
+
);
|
|
232
252
|
const badges: { text: string; tone?: TspTone; title?: string }[] = [{ text: "you", tone: "user" }];
|
|
233
253
|
if (input.excluded) badges.push({ text: "not sent", title: "Not sent to the model", tone: "muted" });
|
|
234
|
-
const hasBody = body.length > 0;
|
|
235
254
|
return node(
|
|
236
255
|
"tool",
|
|
237
256
|
{
|
|
238
257
|
role: input.role,
|
|
239
258
|
name: input.name,
|
|
240
259
|
title: input.title,
|
|
241
|
-
|
|
242
|
-
targetKind: "command",
|
|
243
|
-
lang: input.lang,
|
|
260
|
+
command: input.command,
|
|
244
261
|
badges,
|
|
245
262
|
status: EXECUTION_CARD_STATUS[input.status],
|
|
246
263
|
age: running ? ms : undefined,
|
|
@@ -248,11 +265,11 @@ export function describeExecutionTool(input: ExecutionToolInput): NativeNode {
|
|
|
248
265
|
exit: input.status === "error" ? input.exitCode : undefined,
|
|
249
266
|
note: input.status === "cancelled" ? "cancelled" : undefined,
|
|
250
267
|
frame: "card",
|
|
251
|
-
collapsible:
|
|
252
|
-
collapsed:
|
|
253
|
-
preview:
|
|
268
|
+
collapsible: true,
|
|
269
|
+
collapsed: !input.expanded,
|
|
270
|
+
preview: "children",
|
|
254
271
|
},
|
|
255
|
-
|
|
272
|
+
[box, ...(input.images ?? [])],
|
|
256
273
|
);
|
|
257
274
|
}
|
|
258
275
|
|
|
@@ -111,10 +111,13 @@ function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined):
|
|
|
111
111
|
|
|
112
112
|
type ToolRendererStage = "call" | "result";
|
|
113
113
|
|
|
114
|
-
/**
|
|
114
|
+
/**
|
|
115
|
+
* A renderer's preview as the fallback card's clamp (`tail` becomes a head
|
|
116
|
+
* clamp of the same size; `children` leaves the clamping to the body's own previews).
|
|
117
|
+
*/
|
|
115
118
|
function cardPreview(preview: NativeToolView["preview"]): TspPreview | undefined {
|
|
116
119
|
if (preview === undefined) return { lines: DEFAULT_TERMINAL_PREVIEW_LINES };
|
|
117
|
-
if (preview === "none") return undefined;
|
|
120
|
+
if (preview === "none" || preview === "children") return undefined;
|
|
118
121
|
if (preview !== "auto" && "tail" in preview) return { lines: preview.tail };
|
|
119
122
|
return preview;
|
|
120
123
|
}
|
|
@@ -1025,6 +1028,7 @@ export class ToolExecutionComponent extends Container {
|
|
|
1025
1028
|
target: head?.target,
|
|
1026
1029
|
targetKind: head?.targetKind,
|
|
1027
1030
|
lang: head?.lang,
|
|
1031
|
+
command: head?.command,
|
|
1028
1032
|
href: head?.href,
|
|
1029
1033
|
meta: head?.meta,
|
|
1030
1034
|
badges: badges.length > 0 ? badges : undefined,
|
|
@@ -1124,11 +1128,14 @@ export class ToolExecutionComponent extends Container {
|
|
|
1124
1128
|
* A renderer that throws falls back to the generic card.
|
|
1125
1129
|
*/
|
|
1126
1130
|
#nativeView(): NativeToolView {
|
|
1131
|
+
const status = this.#nativeStatus();
|
|
1127
1132
|
const options: RenderResultContextOptions = {
|
|
1128
1133
|
expanded: this.#expanded,
|
|
1129
1134
|
isPartial: this.#isPartial,
|
|
1130
1135
|
argsComplete: this.#argsComplete,
|
|
1131
1136
|
executionStarted: this.#executionStarted,
|
|
1137
|
+
elapsedMs: this.#elapsedMs(status),
|
|
1138
|
+
cancelled: status === "cancelled",
|
|
1132
1139
|
renderContext: this.#buildRenderContext(),
|
|
1133
1140
|
};
|
|
1134
1141
|
// Custom tools (MCP, extensions) carry their own describe hooks, mirroring
|
package/src/components/editor.ts
CHANGED
|
@@ -83,6 +83,7 @@ const VIM_MODE_LABELS: Record<VimMode, string | undefined> = {
|
|
|
83
83
|
normal: "NORMAL",
|
|
84
84
|
visual: "VISUAL",
|
|
85
85
|
"visual-line": "VISUAL",
|
|
86
|
+
replace: "REPLACE",
|
|
86
87
|
};
|
|
87
88
|
|
|
88
89
|
const PASSTHROUGH_COLOR = (text: string): string => text;
|
|
@@ -672,7 +673,9 @@ export class Editor implements Component, Focusable {
|
|
|
672
673
|
// Emacs-style kill ring
|
|
673
674
|
#killRing = new KillRing();
|
|
674
675
|
/** Previous edit, for kill/yank chaining, undo coalescing, and the provisional space after a Tab word accept. */
|
|
675
|
-
#lastAction: "kill" | "yank" | "type-word" | "accept-word" | null = null;
|
|
676
|
+
#lastAction: "kill" | "yank" | "type-word" | "accept-word" | "replace" | null = null;
|
|
677
|
+
/** Graphemes overwritten in the current `R` session, so Backspace can restore them. */
|
|
678
|
+
#replaceLog: { line: number; col: number; removed: string; text: string }[] = [];
|
|
676
679
|
|
|
677
680
|
// Character jump mode
|
|
678
681
|
#jumpMode: "forward" | "backward" | null = null;
|
|
@@ -2252,18 +2255,43 @@ export class Editor implements Component, Focusable {
|
|
|
2252
2255
|
return this.isShowingAutocomplete() ? false : this.#runVimKey("escape", vim);
|
|
2253
2256
|
}
|
|
2254
2257
|
if (vim.mode === "insert") return false;
|
|
2258
|
+
if (vim.mode === "replace") {
|
|
2259
|
+
if (canonical === "backspace" || matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
|
|
2260
|
+
this.#replaceBackspace();
|
|
2261
|
+
return true;
|
|
2262
|
+
}
|
|
2263
|
+
const replacing = extractPrintableText(data);
|
|
2264
|
+
if (replacing) {
|
|
2265
|
+
for (const seg of segmenter.segment(replacing)) this.#overwriteReplaceGrapheme(seg.segment);
|
|
2266
|
+
return true;
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2255
2269
|
|
|
2270
|
+
// `f`/`t`/`r` are waiting for a character. Space is that character; arrows and other named
|
|
2271
|
+
// keys cancel, instead of being rewritten to `h`/`l` and inserted or searched for.
|
|
2272
|
+
const awaitingTarget = /[fFtTr]$/.test(vim.pendingText);
|
|
2273
|
+
if (awaitingTarget && (canonical === "space" || data === " ")) return this.#runVimKey(" ", vim);
|
|
2256
2274
|
const mapped = canonical === undefined ? undefined : VIM_NAV_KEYS[canonical];
|
|
2275
|
+
if (awaitingTarget && mapped !== undefined) return this.#runVimKey("escape", vim);
|
|
2257
2276
|
if (mapped !== undefined) return this.#runVimKey(mapped, vim);
|
|
2258
2277
|
|
|
2259
|
-
// Control chords carry no printable text and stay with the host.
|
|
2278
|
+
// Control chords, Enter, and Tab carry no printable text and stay with the host. A half-typed
|
|
2279
|
+
// command (`r`, `f`, `d`) is cancelled first, like character-jump mode, so it cannot outlive a
|
|
2280
|
+
// submit and swallow the first key of the next prompt.
|
|
2260
2281
|
const printable = extractPrintableText(data);
|
|
2261
|
-
if (!printable)
|
|
2282
|
+
if (!printable) {
|
|
2283
|
+
if (vim.pending) this.#runVimKey("escape", vim);
|
|
2284
|
+
return false;
|
|
2285
|
+
}
|
|
2262
2286
|
|
|
2263
2287
|
// Batched stdin can deliver several keystrokes at once, so replay the run one grapheme at a
|
|
2264
|
-
// time. A command that drops out of Normal mode part-way (`iabc`) turns the rest of
|
|
2265
|
-
//
|
|
2288
|
+
// time. A command that drops out of Normal mode part-way (`iabc`, `Rxx`) turns the rest of
|
|
2289
|
+
// the run into literal text or replace-mode overwrites rather than more Normal commands.
|
|
2266
2290
|
for (const seg of segmenter.segment(printable)) {
|
|
2291
|
+
if (vim.mode === "replace") {
|
|
2292
|
+
this.#overwriteReplaceGrapheme(seg.segment);
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2267
2295
|
if (this.#runVimKey(seg.segment, vim)) continue;
|
|
2268
2296
|
this.#insertCharacter(printable.slice(seg.index));
|
|
2269
2297
|
return true;
|
|
@@ -2284,11 +2312,22 @@ export class Editor implements Component, Focusable {
|
|
|
2284
2312
|
if (vim.mode !== before || vim.pendingText !== pendingBefore || this.vimSelectedLines !== selectedLinesBefore) {
|
|
2285
2313
|
this.onVimModeChange?.(vim.mode);
|
|
2286
2314
|
}
|
|
2315
|
+
if (before === "replace" && vim.mode !== "replace") this.#replaceLog.length = 0;
|
|
2316
|
+
if (vim.mode === "replace" && before !== "replace") this.#replaceLog.length = 0;
|
|
2287
2317
|
return true;
|
|
2288
2318
|
}
|
|
2289
2319
|
|
|
2290
2320
|
#applyVimCommands(commands: readonly VimCommand[]): void {
|
|
2291
2321
|
for (const command of commands) {
|
|
2322
|
+
// A delete or other edit in replace mode invalidates Backspace restores. Motions do not.
|
|
2323
|
+
if (
|
|
2324
|
+
this.#vim?.mode === "replace" &&
|
|
2325
|
+
command.kind !== "move" &&
|
|
2326
|
+
command.kind !== "mode" &&
|
|
2327
|
+
command.kind !== "yank"
|
|
2328
|
+
) {
|
|
2329
|
+
this.#replaceLog.length = 0;
|
|
2330
|
+
}
|
|
2292
2331
|
switch (command.kind) {
|
|
2293
2332
|
case "move":
|
|
2294
2333
|
this.#moveVimCursor(command.to);
|
|
@@ -2320,12 +2359,133 @@ export class Editor implements Component, Focusable {
|
|
|
2320
2359
|
case "undo":
|
|
2321
2360
|
this.#applyUndo();
|
|
2322
2361
|
break;
|
|
2362
|
+
case "replace":
|
|
2363
|
+
this.#replaceVimSpan(command.from, command.to, command.text);
|
|
2364
|
+
break;
|
|
2365
|
+
case "join":
|
|
2366
|
+
this.#joinVimLines(command.fromLine, command.lines);
|
|
2367
|
+
break;
|
|
2368
|
+
case "indent":
|
|
2369
|
+
this.#indentVimLines(command.fromLine, command.toLine, command.out);
|
|
2370
|
+
break;
|
|
2323
2371
|
}
|
|
2324
2372
|
}
|
|
2325
2373
|
this.#clampVimCursor();
|
|
2326
2374
|
this.invalidate();
|
|
2327
2375
|
}
|
|
2328
2376
|
|
|
2377
|
+
#replaceVimSpan(from: VimPosition, to: VimPosition, text: string): void {
|
|
2378
|
+
const line = this.#state.lines[from.line] ?? "";
|
|
2379
|
+
if (this.#spanCutsAtomicToken(line, from.col, to.col)) return;
|
|
2380
|
+
this.#recordUndoState();
|
|
2381
|
+
this.#lastAction = null;
|
|
2382
|
+
this.#state.lines[from.line] = line.slice(0, from.col) + text + line.slice(to.col);
|
|
2383
|
+
this.#state.cursorLine = from.line;
|
|
2384
|
+
let last = from.col;
|
|
2385
|
+
for (const seg of segmenter.segment(text)) last = from.col + seg.index;
|
|
2386
|
+
this.#setCursorCol(last);
|
|
2387
|
+
this.#afterVimEdit();
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
#overwriteReplaceGrapheme(grapheme: string): void {
|
|
2391
|
+
const lineIdx = this.#state.cursorLine;
|
|
2392
|
+
const line = this.#state.lines[lineIdx] ?? "";
|
|
2393
|
+
const col = this.#state.cursorCol;
|
|
2394
|
+
if (this.#atomicTokenAt(line, col)) return;
|
|
2395
|
+
// Any other edit since the last overwrite (setText, `x`, paste) resets `#lastAction`, which
|
|
2396
|
+
// makes the logged columns meaningless; start a fresh undo step and a fresh log.
|
|
2397
|
+
if (this.#lastAction !== "replace") {
|
|
2398
|
+
this.#recordUndoState();
|
|
2399
|
+
this.#replaceLog.length = 0;
|
|
2400
|
+
}
|
|
2401
|
+
this.#lastAction = "replace";
|
|
2402
|
+
// Editing a recalled prompt makes it a draft, so Up/Down at an edge move the cursor again
|
|
2403
|
+
// instead of navigating history over the overwritten text (same as `#afterVimEdit`).
|
|
2404
|
+
this.#historyIndex = -1;
|
|
2405
|
+
const end = col >= line.length ? col : nextGraphemeStart(line, col);
|
|
2406
|
+
const removed = line.slice(col, end);
|
|
2407
|
+
this.#state.lines[lineIdx] = line.slice(0, col) + grapheme + line.slice(end);
|
|
2408
|
+
this.#replaceLog.push({ line: lineIdx, col, removed, text: grapheme });
|
|
2409
|
+
this.#setCursorCol(col + grapheme.length);
|
|
2410
|
+
this.#notifyChange();
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
#replaceBackspace(): void {
|
|
2414
|
+
if (this.#lastAction !== "replace") {
|
|
2415
|
+
this.#replaceLog.length = 0;
|
|
2416
|
+
return;
|
|
2417
|
+
}
|
|
2418
|
+
const entry = this.#replaceLog.pop();
|
|
2419
|
+
if (!entry) return;
|
|
2420
|
+
const line = this.#state.lines[entry.line] ?? "";
|
|
2421
|
+
const stillThere = line.slice(entry.col, entry.col + entry.text.length) === entry.text;
|
|
2422
|
+
const atCursor = this.#state.cursorLine === entry.line && this.#state.cursorCol === entry.col + entry.text.length;
|
|
2423
|
+
if (!stillThere) return;
|
|
2424
|
+
if (!atCursor) {
|
|
2425
|
+
this.#replaceLog.push(entry);
|
|
2426
|
+
return;
|
|
2427
|
+
}
|
|
2428
|
+
this.#state.lines[entry.line] =
|
|
2429
|
+
line.slice(0, entry.col) + entry.removed + line.slice(entry.col + entry.text.length);
|
|
2430
|
+
this.#setCursorCol(entry.col);
|
|
2431
|
+
this.#notifyChange();
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
#joinVimLines(fromLine: number, span: number): void {
|
|
2435
|
+
const start = Math.max(0, Math.min(fromLine, this.#state.lines.length - 1));
|
|
2436
|
+
const last = Math.min(start + Math.max(span, 2) - 1, this.#state.lines.length - 1);
|
|
2437
|
+
if (last <= start) return;
|
|
2438
|
+
this.#recordUndoState();
|
|
2439
|
+
let text = this.#state.lines[start] ?? "";
|
|
2440
|
+
let cursor = text.length;
|
|
2441
|
+
for (let i = start + 1; i <= last; i++) {
|
|
2442
|
+
const raw = this.#state.lines[i] ?? "";
|
|
2443
|
+
let trim = /^[ \t]*/.exec(raw)?.[0].length ?? 0;
|
|
2444
|
+
const token = this.#atomicTokenAt(raw, 0);
|
|
2445
|
+
if (token && token.start < trim) trim = token.start;
|
|
2446
|
+
const next = raw.slice(trim);
|
|
2447
|
+
const at = text.length;
|
|
2448
|
+
if (i === start + 1) cursor = at;
|
|
2449
|
+
// Vim join: no extra space after whitespace, before `)`, or for an empty line; two spaces after `.!?`.
|
|
2450
|
+
const gap =
|
|
2451
|
+
text.length === 0 || next.length === 0 || /[ \t]$/.test(text) || next.startsWith(")")
|
|
2452
|
+
? ""
|
|
2453
|
+
: /[.!?]$/.test(text)
|
|
2454
|
+
? " "
|
|
2455
|
+
: " ";
|
|
2456
|
+
text += gap + next;
|
|
2457
|
+
}
|
|
2458
|
+
this.#state.lines.splice(start, last - start + 1, text);
|
|
2459
|
+
this.#state.cursorLine = start;
|
|
2460
|
+
this.#setCursorCol(cursor);
|
|
2461
|
+
this.#lastAction = null;
|
|
2462
|
+
this.#afterVimEdit();
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
#indentVimLines(fromLine: number, toLine: number, out: boolean): void {
|
|
2466
|
+
const first = Math.max(0, Math.min(fromLine, this.#state.lines.length - 1));
|
|
2467
|
+
const last = Math.max(first, Math.min(toLine, this.#state.lines.length - 1));
|
|
2468
|
+
// Two spaces, not Vim's shiftwidth of 8: a prompt draft should not jump a tab stop. A leading tab shifts by one tab so the line keeps its own indent style.
|
|
2469
|
+
// Empty lines stay empty, as in Vim, so shifting a paragraph never leaves trailing blanks.
|
|
2470
|
+
const next = this.#state.lines.slice(first, last + 1).map(line => {
|
|
2471
|
+
const text = line ?? "";
|
|
2472
|
+
if (!out) return text.length === 0 ? text : (text.startsWith("\t") ? "\t" : " ") + text;
|
|
2473
|
+
if (text.startsWith("\t")) return text.slice(1);
|
|
2474
|
+
const spaces = /^ */.exec(text)?.[0].length ?? 0;
|
|
2475
|
+
return text.slice(Math.min(2, spaces));
|
|
2476
|
+
});
|
|
2477
|
+
if (next.every((line, i) => line === (this.#state.lines[first + i] ?? ""))) return;
|
|
2478
|
+
this.#recordUndoState();
|
|
2479
|
+
for (let i = first; i <= last; i++) this.#state.lines[i] = next[i - first] ?? "";
|
|
2480
|
+
this.#state.cursorLine = first;
|
|
2481
|
+
const landed = this.#state.lines[first] ?? "";
|
|
2482
|
+
let col = 0;
|
|
2483
|
+
while (col < landed.length && /\s/.test(landed.charAt(col))) col++;
|
|
2484
|
+
this.#setCursorCol(col);
|
|
2485
|
+
this.#lastAction = null;
|
|
2486
|
+
this.#afterVimEdit();
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2329
2489
|
#moveVimCursor(to: VimPosition): void {
|
|
2330
2490
|
this.#state.cursorLine = Math.max(0, Math.min(to.line, this.#state.lines.length - 1));
|
|
2331
2491
|
const line = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
@@ -3409,6 +3569,23 @@ export class Editor implements Component, Focusable {
|
|
|
3409
3569
|
return undefined;
|
|
3410
3570
|
}
|
|
3411
3571
|
|
|
3572
|
+
/** True when `[from, to)` overlaps a placeholder. Counted `r` must no-op rather than split it.
|
|
3573
|
+
* One pass over the line's matches, so a long counted replace stays linear. */
|
|
3574
|
+
#spanCutsAtomicToken(line: string, from: number, to: number): boolean {
|
|
3575
|
+
const re = this.#getAtomicTokenRe();
|
|
3576
|
+
if (re === undefined) return false;
|
|
3577
|
+
re.lastIndex = 0;
|
|
3578
|
+
for (;;) {
|
|
3579
|
+
const match = re.exec(line);
|
|
3580
|
+
if (match === null || match.index >= to) return false;
|
|
3581
|
+
if (match[0].length === 0) {
|
|
3582
|
+
re.lastIndex = match.index + 1;
|
|
3583
|
+
continue;
|
|
3584
|
+
}
|
|
3585
|
+
if (match.index + match[0].length > from) return true;
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3412
3589
|
/** Expand the half-open range [start, end) so it never cuts through an atomic
|
|
3413
3590
|
* placeholder token: a boundary landing inside a token pulls the whole token in. */
|
|
3414
3591
|
#expandRangeOverAtomicTokens(line: string, start: number, end: number): { start: number; end: number } {
|
|
@@ -53,11 +53,15 @@ export function matchesSelectPageDown(data: string): boolean {
|
|
|
53
53
|
return getKeybindings().matches(data, "tui.select.pageDown");
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** Effective external-editor key: the first bound key, or Ctrl+G when unbound. */
|
|
57
|
+
export function appExternalEditorKey(): KeyId {
|
|
58
|
+
return getKeybindings().getKeys("app.editor.external")[0] ?? "ctrl+g";
|
|
59
|
+
}
|
|
60
|
+
|
|
56
61
|
/** Match the external-editor keybinding, falling back to Ctrl+G when unbound. */
|
|
57
62
|
export function matchesAppExternalEditor(data: string): boolean {
|
|
58
63
|
const keybindings = getKeybindings();
|
|
59
|
-
|
|
60
|
-
if (externalEditorKeys.length > 0) {
|
|
64
|
+
if (keybindings.getKeys("app.editor.external").length > 0) {
|
|
61
65
|
return keybindings.matches(data, "app.editor.external");
|
|
62
66
|
}
|
|
63
67
|
return matchesKey(data, "ctrl+g");
|
|
@@ -951,12 +951,16 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
|
|
|
951
951
|
return { ...item, depth, open: this.#childrenByParent.has(ref.id) ? true : undefined };
|
|
952
952
|
}
|
|
953
953
|
if (!metrics) return item;
|
|
954
|
+
// A running agent's active time ticks terminal-side from the age at send; a settled one
|
|
955
|
+
// is frozen text. Progress `durationMs` was current when its frame arrived, so age it to now
|
|
956
|
+
// — re-sending the stale value on every repaint rewinds Tern's clock. A first frame can
|
|
957
|
+
// report 0 ms, so a progress timestamp alone makes the row clockable.
|
|
958
|
+
const progressAt = observed?.progress ? observed.progressAt : undefined;
|
|
954
959
|
const facts: Record<string, string | number> = {
|
|
955
960
|
cost: formatCost(metrics.cost),
|
|
956
|
-
// A running agent's active time ticks terminal-side; a settled one is frozen text.
|
|
957
961
|
time:
|
|
958
|
-
ref.status === "running" && metrics.durationMs > 0
|
|
959
|
-
? metrics.durationMs
|
|
962
|
+
ref.status === "running" && (metrics.durationMs > 0 || progressAt !== undefined)
|
|
963
|
+
? metrics.durationMs + (progressAt === undefined ? 0 : Math.max(0, Date.now() - progressAt))
|
|
960
964
|
: formatDuration(metrics.durationMs),
|
|
961
965
|
req: metrics.requests,
|
|
962
966
|
tools: metrics.tools,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "../index";
|
|
14
14
|
import { compositeLineAt } from "../render/composite";
|
|
15
15
|
import { wrapLiteralLine } from "../utils";
|
|
16
|
-
import {
|
|
16
|
+
import { editorKey } from "../chrome/keybinding-hints";
|
|
17
17
|
import { formatKeyHint, formatKeyHints, type KeybindingsManager } from "../app-keybindings";
|
|
18
18
|
import type { Keybinding } from "../keybindings";
|
|
19
19
|
import { sanitizeText } from "@oh-my-pi/pi-utils";
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
topBorder,
|
|
39
39
|
topBorderSplit,
|
|
40
40
|
} from "../chrome/overlay-box";
|
|
41
|
-
import { matchesAppExternalEditor } from "../keybinding-matchers";
|
|
41
|
+
import { appExternalEditorKey, matchesAppExternalEditor } from "../keybinding-matchers";
|
|
42
42
|
import type { KeyName } from "../key-hint-format";
|
|
43
43
|
import { item, keyed, node, row as rowNode, span, text } from "../native/describe";
|
|
44
44
|
import { leafKey, type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
|
|
@@ -70,6 +70,8 @@ export interface AnnotationOverlayCallbacks {
|
|
|
70
70
|
onWarning?(message: string): void;
|
|
71
71
|
/** Open the draft in the host's editor and invoke `commit` with its result. */
|
|
72
72
|
onAnnotationExternalEditor?(draft: string, commit: (text: string | null) => void): void | Promise<void>;
|
|
73
|
+
/** Open the current source in the host's editor. The overlay stays open. */
|
|
74
|
+
onExternalEditor?(): void | Promise<void>;
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
export interface CodeReviewOverlayCallbacks extends AnnotationOverlayCallbacks {
|
|
@@ -171,6 +173,8 @@ export class AnnotationOverlay implements Focusable {
|
|
|
171
173
|
#editingAnnotationIndex: number | undefined;
|
|
172
174
|
#annotationChooser: AnnotationChooser | undefined;
|
|
173
175
|
#externalOperation = false;
|
|
176
|
+
/** Set once {@link replaceTextSource} runs; the paste result then carries the edited text. */
|
|
177
|
+
#textSourceEdited = false;
|
|
174
178
|
#finished = false;
|
|
175
179
|
#annotations: CommittedAnnotation[] = [];
|
|
176
180
|
#textAnnotations: CommittedTextAnnotation[] = [];
|
|
@@ -306,6 +310,61 @@ export class AnnotationOverlay implements Focusable {
|
|
|
306
310
|
return this.#textAnnotations.map(entry => ({ ...entry.annotation }));
|
|
307
311
|
}
|
|
308
312
|
|
|
313
|
+
/** Repo-relative path of the diff file under the cursor, if this is a code review. */
|
|
314
|
+
reviewFilePath(): string | undefined {
|
|
315
|
+
const file = this.#files[this.#fileIndex];
|
|
316
|
+
if (!file) return undefined;
|
|
317
|
+
return file.newPath ?? file.oldPath ?? file.path;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Current text-source bytes, including edits applied by {@link replaceTextSource}. */
|
|
321
|
+
textSourceText(): string | undefined {
|
|
322
|
+
return this.#textSource?.text;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Replace the text under review. Line notes whose quote still sits on the
|
|
327
|
+
* same row stay; a quote that moved to exactly one row is retargeted;
|
|
328
|
+
* every other line note is dropped. Returns how many were dropped.
|
|
329
|
+
*/
|
|
330
|
+
replaceTextSource(text: string): number {
|
|
331
|
+
if (!this.#textSource) return 0;
|
|
332
|
+
this.#textSource = { ...this.#textSource, text };
|
|
333
|
+
this.#textSourceEdited = true;
|
|
334
|
+
this.#textLines = splitTextLines(text);
|
|
335
|
+
this.#viewportDriven = false;
|
|
336
|
+
this.#sourceIndex = Math.min(this.#sourceIndex, Math.max(0, this.#textLines.length - 1));
|
|
337
|
+
let dropped = 0;
|
|
338
|
+
const kept: CommittedTextAnnotation[] = [];
|
|
339
|
+
for (const entry of this.#textAnnotations) {
|
|
340
|
+
if (entry.annotation.scope !== "line") {
|
|
341
|
+
kept.push(entry);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const quote = entry.annotation.quote;
|
|
345
|
+
if (this.#textLines[entry.sourceIndex] === quote) {
|
|
346
|
+
kept.push(entry);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const matches = this.#textLines.flatMap((line, index) => (line === quote ? [index] : []));
|
|
350
|
+
const only = matches.length === 1 ? matches[0] : undefined;
|
|
351
|
+
if (only !== undefined) {
|
|
352
|
+
kept.push({
|
|
353
|
+
sourceIndex: only,
|
|
354
|
+
annotation: { ...entry.annotation, line: only + 1 },
|
|
355
|
+
});
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
dropped++;
|
|
359
|
+
}
|
|
360
|
+
this.#textAnnotations = kept;
|
|
361
|
+
// Earlier snapshots hold notes anchored to the old text; undo must not resurrect them.
|
|
362
|
+
this.#undoStack = [];
|
|
363
|
+
this.#annotationRev++;
|
|
364
|
+
this.#nativeBody = undefined;
|
|
365
|
+
return dropped;
|
|
366
|
+
}
|
|
367
|
+
|
|
309
368
|
handleInput(data: string): void {
|
|
310
369
|
if (this.#finished || this.#externalOperation) return;
|
|
311
370
|
if (this.#annotationChooser) {
|
|
@@ -328,6 +387,10 @@ export class AnnotationOverlay implements Focusable {
|
|
|
328
387
|
this.#escape();
|
|
329
388
|
return;
|
|
330
389
|
}
|
|
390
|
+
if (this.#callbacks.onExternalEditor && matchesAppExternalEditor(data)) {
|
|
391
|
+
void this.#openSourceEditor();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
331
394
|
if (data === "A" && (this.#focus === "files" || this.#focus === "diff")) {
|
|
332
395
|
this.#startAnnotation(this.#textSource ? "text" : "file");
|
|
333
396
|
return;
|
|
@@ -514,6 +577,7 @@ export class AnnotationOverlay implements Focusable {
|
|
|
514
577
|
this.#finish({
|
|
515
578
|
action: "paste",
|
|
516
579
|
annotations: this.getTextAnnotations(),
|
|
580
|
+
...(this.#textSourceEdited ? { editedText: this.#textSource.text } : {}),
|
|
517
581
|
});
|
|
518
582
|
} else {
|
|
519
583
|
this.#finish({
|
|
@@ -832,6 +896,22 @@ export class AnnotationOverlay implements Focusable {
|
|
|
832
896
|
}
|
|
833
897
|
}
|
|
834
898
|
|
|
899
|
+
async #openSourceEditor(): Promise<void> {
|
|
900
|
+
const openEditor = this.#callbacks.onExternalEditor;
|
|
901
|
+
if (this.#externalOperation || !openEditor) return;
|
|
902
|
+
this.#externalOperation = true;
|
|
903
|
+
try {
|
|
904
|
+
await openEditor();
|
|
905
|
+
} catch (error) {
|
|
906
|
+
this.#callbacks.onWarning?.(
|
|
907
|
+
`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`,
|
|
908
|
+
);
|
|
909
|
+
} finally {
|
|
910
|
+
this.#externalOperation = false;
|
|
911
|
+
this.#tui.requestRender(true);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
835
915
|
#annotationCount(fileIndex: number): number {
|
|
836
916
|
let count = 0;
|
|
837
917
|
for (const entry of this.#annotations) if (entry.fileIndex === fileIndex) count++;
|
|
@@ -1091,8 +1171,7 @@ export class AnnotationOverlay implements Focusable {
|
|
|
1091
1171
|
`${editorKey("tui.input.newLine")} newline`,
|
|
1092
1172
|
`${this.#key("tui.select.cancel")} cancel`,
|
|
1093
1173
|
];
|
|
1094
|
-
|
|
1095
|
-
if (externalEditorKey) hints.push(`${externalEditorKey} editor`);
|
|
1174
|
+
hints.push(`${formatKeyHint(appExternalEditorKey())} editor`);
|
|
1096
1175
|
return [caption, ...this.#editor.render(width), this.#theme.fg("dim", hints.join(" · "))];
|
|
1097
1176
|
}
|
|
1098
1177
|
return [this.#theme.fg("dim", this.#helpText())];
|
|
@@ -1143,7 +1222,8 @@ export class AnnotationOverlay implements Focusable {
|
|
|
1143
1222
|
: this.#focus === "diff"
|
|
1144
1223
|
? `${upDown} line · ${formatKeyHint("shift")} faster · ${this.#key("tui.select.pageUp")}/${this.#key("tui.select.pageDown")} · ${formatKeyHints(["g", "shift+g"])} ends · ${formatKeyHint("a")} line note · ${formatKeyHint("shift+a")} ${this.#textSource ? "text" : "file"} note · ${editNote}`
|
|
1145
1224
|
: `${upDown} select · ${confirm} confirm`;
|
|
1146
|
-
|
|
1225
|
+
const editorHint = this.#callbacks.onExternalEditor ? ` · ${formatKeyHint(appExternalEditorKey())} editor` : "";
|
|
1226
|
+
return `${focusHelp} · ${formatKeyHint("u")} undo · ${formatKeyHint("tab")} regions${editorHint} · ${this.#key("tui.select.cancel")} cancel`;
|
|
1147
1227
|
}
|
|
1148
1228
|
|
|
1149
1229
|
#ensureCursorVisible(renderedRowBySource: readonly number[]): boolean {
|
|
@@ -1523,7 +1603,7 @@ export class AnnotationOverlay implements Focusable {
|
|
|
1523
1603
|
this.#hint("tui.input.submit", "save"),
|
|
1524
1604
|
this.#hint("tui.input.newLine", "newline"),
|
|
1525
1605
|
this.#hint("tui.select.cancel", "cancel"),
|
|
1526
|
-
|
|
1606
|
+
{ keys: [appExternalEditorKey()], label: "editor" },
|
|
1527
1607
|
],
|
|
1528
1608
|
"annotateHints",
|
|
1529
1609
|
),
|
|
@@ -1549,6 +1629,7 @@ export class AnnotationOverlay implements Focusable {
|
|
|
1549
1629
|
editNote,
|
|
1550
1630
|
]
|
|
1551
1631
|
: [upDown("select"), this.#hint("tui.select.confirm", "confirm")];
|
|
1632
|
+
if (this.#callbacks.onExternalEditor) hints.push({ keys: [appExternalEditorKey()], label: "editor" });
|
|
1552
1633
|
hints.push(key("u", "undo"), key("tab", "regions"), this.#hint("tui.select.cancel", "cancel"));
|
|
1553
1634
|
return [hintsRow(hints)];
|
|
1554
1635
|
}
|
|
@@ -94,6 +94,10 @@ export interface HookSelectorOptions {
|
|
|
94
94
|
/** Number of leading options (original order) that receive a selection
|
|
95
95
|
* marker. Defaults to every option when {@link selectionMarker} is set. */
|
|
96
96
|
markableCount?: number;
|
|
97
|
+
/** Describe as a card in the dock (the composer slot) instead of a picker
|
|
98
|
+
* sheet, so dock content above it (e.g. a `/omfg` candidate rule) stays
|
|
99
|
+
* visible and the transcript stays scrollable on native surfaces. */
|
|
100
|
+
inline?: boolean;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
103
|
export interface HookSelectorOption {
|
|
@@ -210,6 +214,7 @@ export class HookSelectorComponent extends OverlayPanel {
|
|
|
210
214
|
#sliderComponent: Text | undefined;
|
|
211
215
|
#lastRenderWidth: number | undefined;
|
|
212
216
|
readonly #detailLines: readonly string[];
|
|
217
|
+
readonly #inline: boolean;
|
|
213
218
|
readonly #helpText: string | undefined;
|
|
214
219
|
/** Described option rows by original index; `marker` is the radio state baked into the label. */
|
|
215
220
|
readonly #nativeItems = new Map<number, { marker: boolean; node: NativeNode }>();
|
|
@@ -264,6 +269,7 @@ export class HookSelectorComponent extends OverlayPanel {
|
|
|
264
269
|
this.#baseTitle = this.title;
|
|
265
270
|
this.#detailLines = title.split(/\r?\n/).slice(1);
|
|
266
271
|
this.#helpText = opts?.helpText;
|
|
272
|
+
this.#inline = opts?.inline ?? false;
|
|
267
273
|
this.#onLeftCallback = opts?.onLeft;
|
|
268
274
|
this.#onRightCallback = opts?.onRight;
|
|
269
275
|
this.#onExternalEditorCallback = opts?.onExternalEditor;
|
|
@@ -726,13 +732,14 @@ export class HookSelectorComponent extends OverlayPanel {
|
|
|
726
732
|
}
|
|
727
733
|
|
|
728
734
|
/**
|
|
729
|
-
* A plain option list is a picker sheet. Otherwise
|
|
730
|
-
* title (plus the countdown `elapsed` when a
|
|
731
|
-
* lines, the slider as `tabs`, the options as
|
|
732
|
-
* option index, the search query while typing,
|
|
735
|
+
* A plain option list is a picker sheet. Otherwise (inline, slider, or
|
|
736
|
+
* countdown) a card headed by the title (plus the countdown `elapsed` when a
|
|
737
|
+
* timeout runs), the extra title lines, the slider as `tabs`, the options as
|
|
738
|
+
* a `list` keyed by original option index, the search query while typing,
|
|
739
|
+
* and the key hints.
|
|
733
740
|
*/
|
|
734
741
|
override describe(cx: DescribeContext): NativeNode {
|
|
735
|
-
if (cx.supports("picker") && !this.#slider && !this.#countdown) return this.#describePicker();
|
|
742
|
+
if (cx.supports("picker") && !this.#inline && !this.#slider && !this.#countdown) return this.#describePicker();
|
|
736
743
|
const items = this.#menu.visibleItems;
|
|
737
744
|
const selected = this.#menu.selectedKey;
|
|
738
745
|
const query = this.#menu.query;
|