@oh-my-pi/pi-tui 18.3.0 → 18.3.2
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 +24 -0
- package/THIRD-PARTY-NOTICES.txt +2 -2
- package/dist/types/chat/read-tool-group.d.ts +3 -3
- package/dist/types/chat/user-message.d.ts +4 -1
- package/dist/types/components/editor.d.ts +0 -2
- package/dist/types/components/input.d.ts +16 -0
- package/dist/types/overlays/btw-history-panel.d.ts +4 -1
- package/dist/types/overlays/rewind-selector.d.ts +4 -2
- package/dist/types/overlays/settings-defs.d.ts +5 -0
- package/dist/types/prompt/composer.d.ts +12 -1
- package/dist/types/prompt/custom-editor.d.ts +5 -26
- package/dist/types/space-hold.d.ts +45 -0
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/tools/cfg-render.d.ts +37 -0
- package/dist/types/tools/cfg-url.d.ts +16 -0
- package/dist/types/tools/find.d.ts +2 -2
- package/dist/types/tools/output-meta.d.ts +2 -0
- package/dist/types/tools/proc-render.d.ts +1 -8
- package/dist/types/tools/read.d.ts +14 -3
- package/dist/types/tools/result-card.d.ts +20 -0
- package/dist/types/tools/url-scheme-host.d.ts +29 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/tools/xdev.d.ts +9 -1
- package/package.json +9 -9
- package/src/chat/chat-transcript-builder.ts +5 -1
- package/src/chat/read-tool-group.ts +8 -9
- package/src/chat/tool-execution.ts +7 -5
- package/src/chat/user-message.ts +16 -6
- package/src/components/editor.ts +2 -4
- package/src/components/input.ts +103 -30
- package/src/components/markdown.ts +18 -6
- package/src/overlays/annotation-overlay.ts +0 -1
- package/src/overlays/btw-history-panel.ts +4 -0
- package/src/overlays/model-hub.ts +18 -6
- package/src/overlays/rewind-selector.ts +186 -12
- package/src/overlays/settings-defs.ts +6 -0
- package/src/overlays/settings-selector.ts +4 -3
- package/src/prompt/composer.ts +83 -56
- package/src/prompt/custom-editor.ts +15 -137
- package/src/prompt/tips.txt +1 -0
- package/src/space-hold.ts +143 -0
- package/src/status-line/component.ts +6 -0
- package/src/status-line/host.ts +2 -0
- package/src/status-line/segments.ts +4 -0
- package/src/tools/cfg-render.ts +117 -0
- package/src/tools/cfg-url.ts +30 -0
- package/src/tools/find.ts +7 -6
- package/src/tools/output-meta.ts +2 -0
- package/src/tools/proc-render.ts +2 -26
- package/src/tools/read.ts +109 -68
- package/src/tools/result-card.ts +36 -0
- package/src/tools/url-scheme-host.ts +42 -0
- package/src/tools/write.ts +109 -66
- package/src/tools/xdev.ts +9 -1
- package/dist/types/chat/read-target.d.ts +0 -4
- package/src/chat/read-target.ts +0 -7
|
@@ -19,12 +19,15 @@
|
|
|
19
19
|
*
|
|
20
20
|
* Keys: Up/Down step through rendered items in transcript order (within the
|
|
21
21
|
* active column when a strip is open), Left/Right slide between branch
|
|
22
|
-
* variants at a fork and jump between user turns elsewhere,
|
|
23
|
-
* the
|
|
22
|
+
* variants at a fork and jump between user turns elsewhere, `f` opens a
|
|
23
|
+
* filter (typing narrows the current path to matching items; Esc leaves the
|
|
24
|
+
* filter with the selection kept), Enter rewinds to the outlined item, Esc
|
|
25
|
+
* cancels.
|
|
24
26
|
*/
|
|
25
27
|
import type { AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
26
28
|
import {
|
|
27
29
|
type Component,
|
|
30
|
+
extractPrintableText,
|
|
28
31
|
matchesKey,
|
|
29
32
|
padding,
|
|
30
33
|
routeSgrMouseInput,
|
|
@@ -110,6 +113,13 @@ export class RewindSelectorComponent implements Component {
|
|
|
110
113
|
#slide: { from: number; to: number; startedAt: number } | undefined;
|
|
111
114
|
#slideTimer: NodeJS.Timeout | undefined;
|
|
112
115
|
|
|
116
|
+
/** Filter query while the filter prompt is open; undefined shows the full transcript. */
|
|
117
|
+
#filter: string | undefined;
|
|
118
|
+
/** Main transcript rows from the last frame; the filter matches what is on screen. */
|
|
119
|
+
#mainRows: readonly (readonly string[])[] = [];
|
|
120
|
+
/** Lowercased plain text per rendered child row array (row arrays are cached, so identity is stable). */
|
|
121
|
+
#rowText = new WeakMap<readonly string[], string>();
|
|
122
|
+
|
|
113
123
|
constructor(
|
|
114
124
|
entries: TranscriptEntry[],
|
|
115
125
|
private readonly deps: RewindSelectorDeps,
|
|
@@ -235,19 +245,26 @@ export class RewindSelectorComponent implements Component {
|
|
|
235
245
|
});
|
|
236
246
|
return;
|
|
237
247
|
}
|
|
248
|
+
if (this.#filter !== undefined) {
|
|
249
|
+
this.#handleFilterInput(data);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
238
252
|
if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
|
|
239
253
|
this.deps.onCancel();
|
|
240
254
|
return;
|
|
241
255
|
}
|
|
242
|
-
if (
|
|
243
|
-
this.#
|
|
244
|
-
this.#
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
256
|
+
if (matchesKey(data, "f")) {
|
|
257
|
+
this.#filter = "";
|
|
258
|
+
this.#activeVariant = 0;
|
|
259
|
+
this.#siblingSelected = 0;
|
|
260
|
+
this.#stopSlide();
|
|
248
261
|
this.deps.requestRender();
|
|
249
262
|
return;
|
|
250
263
|
}
|
|
264
|
+
if (matchesAppToolsExpand(data)) {
|
|
265
|
+
this.#toggleExpanded();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
251
268
|
if (matchesSelectUp(data)) {
|
|
252
269
|
this.#moveVertical(-1);
|
|
253
270
|
return;
|
|
@@ -282,6 +299,115 @@ export class RewindSelectorComponent implements Component {
|
|
|
282
299
|
}
|
|
283
300
|
}
|
|
284
301
|
|
|
302
|
+
#toggleExpanded(): void {
|
|
303
|
+
this.#expanded = !this.#expanded;
|
|
304
|
+
this.#builder.setExpanded(this.#expanded);
|
|
305
|
+
for (const columns of this.#variantCache.values()) {
|
|
306
|
+
for (const column of columns) column.builder.setExpanded(this.#expanded);
|
|
307
|
+
}
|
|
308
|
+
this.deps.requestRender();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ========================================================================
|
|
312
|
+
// Filter
|
|
313
|
+
// ========================================================================
|
|
314
|
+
|
|
315
|
+
#handleFilterInput(data: string): void {
|
|
316
|
+
if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
|
|
317
|
+
this.#closeFilter();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
|
|
321
|
+
const target = this.#filterMatches().includes(this.#selected) ? this.#targets[this.#selected] : undefined;
|
|
322
|
+
if (target) this.deps.onSelect(target.entryId);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (matchesAppToolsExpand(data)) {
|
|
326
|
+
this.#toggleExpanded();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (matchesSelectUp(data) || matchesKey(data, "left")) {
|
|
330
|
+
const userTurnsOnly = !matchesSelectUp(data);
|
|
331
|
+
this.#stepFiltered(-1, userTurnsOnly);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (matchesSelectDown(data) || matchesKey(data, "right")) {
|
|
335
|
+
const userTurnsOnly = !matchesSelectDown(data);
|
|
336
|
+
this.#stepFiltered(1, userTurnsOnly);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (matchesKey(data, "backspace")) {
|
|
340
|
+
if (this.#filter!.length === 0) {
|
|
341
|
+
this.#closeFilter();
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
this.#setFilter(this.#filter!.slice(0, -1));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
const printable = extractPrintableText(data);
|
|
348
|
+
if (printable) {
|
|
349
|
+
this.#setFilter(this.#filter! + printable);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (this.#browser.handleScrollKey(data)) {
|
|
353
|
+
this.deps.requestRender();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Leave the filter, keeping the selected item outlined in the full transcript. */
|
|
358
|
+
#closeFilter(): void {
|
|
359
|
+
this.#filter = undefined;
|
|
360
|
+
this.deps.requestRender();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Update the query; keep the selection when it still matches, else rest on the newest match above it. */
|
|
364
|
+
#setFilter(query: string): void {
|
|
365
|
+
this.#filter = query;
|
|
366
|
+
const matches = this.#filterMatches();
|
|
367
|
+
if (!matches.includes(this.#selected)) {
|
|
368
|
+
this.#selected = matches.findLast(index => index < this.#selected) ?? matches.at(-1) ?? this.#selected;
|
|
369
|
+
}
|
|
370
|
+
this.deps.requestRender();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
#stepFiltered(delta: -1 | 1, userTurnsOnly: boolean): void {
|
|
374
|
+
const matches = this.#filterMatches().filter(index => !userTurnsOnly || this.#targets[index]!.isUserTurn);
|
|
375
|
+
const next =
|
|
376
|
+
delta < 0 ? matches.findLast(index => index < this.#selected) : matches.find(index => index > this.#selected);
|
|
377
|
+
if (next === undefined) return;
|
|
378
|
+
this.#selected = next;
|
|
379
|
+
this.deps.requestRender();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Visible main-path target indices whose rendered text contains every
|
|
384
|
+
* whitespace-separated query word as a whole word (case-insensitive: `ls`
|
|
385
|
+
* matches `ls -la` but not `tools`); all visible targets for an empty query.
|
|
386
|
+
* Matching the rendered rows keeps results honest: collapsed tool output
|
|
387
|
+
* only matches once Ctrl+O expands it.
|
|
388
|
+
*/
|
|
389
|
+
#filterMatches(): number[] {
|
|
390
|
+
const words = (this.#filter ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
391
|
+
const patterns = words.map(
|
|
392
|
+
word => new RegExp(`(?<![\\p{L}\\p{N}_])${RegExp.escape(word)}(?![\\p{L}\\p{N}_])`, "u"),
|
|
393
|
+
);
|
|
394
|
+
const matches: number[] = [];
|
|
395
|
+
for (let index = 0; index < this.#targets.length; index++) {
|
|
396
|
+
if (!this.#isMainSelectable(index)) continue;
|
|
397
|
+
const target = this.#targets[index]!;
|
|
398
|
+
const texts = this.#mainRows.slice(target.start, target.end).map(rows => {
|
|
399
|
+
let text = this.#rowText.get(rows);
|
|
400
|
+
if (text === undefined) {
|
|
401
|
+
text = Bun.stripANSI(rows.join("\n")).toLowerCase();
|
|
402
|
+
this.#rowText.set(rows, text);
|
|
403
|
+
}
|
|
404
|
+
return text;
|
|
405
|
+
});
|
|
406
|
+
if (patterns.every(pattern => texts.some(text => pattern.test(text)))) matches.push(index);
|
|
407
|
+
}
|
|
408
|
+
return matches;
|
|
409
|
+
}
|
|
410
|
+
|
|
285
411
|
/** Up/Down: step within the active column; leaving a sibling column's top exits the strip. */
|
|
286
412
|
#moveVertical(delta: -1 | 1): void {
|
|
287
413
|
if (this.#activeVariant > 0) {
|
|
@@ -337,6 +463,7 @@ export class RewindSelectorComponent implements Component {
|
|
|
337
463
|
const children = this.#builder.container.children;
|
|
338
464
|
const prepared = this.#browser.prepareOutline(children, this.#targets, this.#selected, contentWidth);
|
|
339
465
|
this.#mainVisible = prepared.visible;
|
|
466
|
+
this.#mainRows = prepared.childRows;
|
|
340
467
|
if (prepared.selected !== this.#selected) {
|
|
341
468
|
// The current target collapsed (e.g. expansion toggle): rest on the
|
|
342
469
|
// nearest visible one and leave the strip.
|
|
@@ -345,6 +472,8 @@ export class RewindSelectorComponent implements Component {
|
|
|
345
472
|
this.#siblingSelected = 0;
|
|
346
473
|
}
|
|
347
474
|
|
|
475
|
+
if (this.#filter !== undefined) return this.#filterFrame(this.#filter, prepared.childRows, contentWidth);
|
|
476
|
+
|
|
348
477
|
const columns = this.#stripColumns();
|
|
349
478
|
const composed =
|
|
350
479
|
columns.length > 0
|
|
@@ -359,14 +488,59 @@ export class RewindSelectorComponent implements Component {
|
|
|
359
488
|
const position = this.#targets.length > 0 ? `${this.#selected + 1}/${this.#targets.length} ` : "";
|
|
360
489
|
const lateral = columns.length > 0 ? "←/→ branches" : "←/→ user turns";
|
|
361
490
|
return {
|
|
362
|
-
header: [
|
|
363
|
-
`${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`,
|
|
364
|
-
],
|
|
491
|
+
header: [this.#header()],
|
|
365
492
|
body: {
|
|
366
493
|
lines: composed.lines,
|
|
367
494
|
anchor: this.#outlineAnchor(composed),
|
|
368
495
|
},
|
|
369
|
-
footer: [
|
|
496
|
+
footer: [
|
|
497
|
+
theme.fg("dim", `${position}↑/↓ step ${lateral} f filter enter rewind ctrl+o expand esc cancel`),
|
|
498
|
+
],
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#header(): string {
|
|
503
|
+
return `${theme.icon.rewind} ${theme.bold("Rewind")}${theme.sep.dot}${theme.fg("dim", "pick the point to continue from")}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Only the matching items of the current path, concatenated; no branch strip. */
|
|
507
|
+
#filterFrame(
|
|
508
|
+
query: string,
|
|
509
|
+
childRows: readonly (readonly string[])[],
|
|
510
|
+
contentWidth: number,
|
|
511
|
+
): TranscriptBrowserFrame {
|
|
512
|
+
const matches = this.#filterMatches();
|
|
513
|
+
const rows: (readonly string[])[] = [];
|
|
514
|
+
const targets: OutlineTarget[] = [];
|
|
515
|
+
for (const index of matches) {
|
|
516
|
+
const target = this.#targets[index]!;
|
|
517
|
+
const start = rows.length;
|
|
518
|
+
rows.push(...childRows.slice(target.start, target.end));
|
|
519
|
+
targets.push({ ...target, start, end: rows.length });
|
|
520
|
+
}
|
|
521
|
+
const selected = matches.indexOf(this.#selected);
|
|
522
|
+
const composed = composeOutlineColumn(rows, 0, rows.length, targets, selected, contentWidth, undefined);
|
|
523
|
+
const lines = matches.length > 0 ? composed.lines : [theme.fg("muted", ` No items match "${query}"`)];
|
|
524
|
+
const count =
|
|
525
|
+
matches.length === 0
|
|
526
|
+
? theme.fg("error", "no matches")
|
|
527
|
+
: theme.fg("dim", `${selected >= 0 ? selected + 1 : "-"}/${matches.length}`);
|
|
528
|
+
return {
|
|
529
|
+
header: [this.#header()],
|
|
530
|
+
body: {
|
|
531
|
+
lines,
|
|
532
|
+
anchor:
|
|
533
|
+
composed.selStart >= 0
|
|
534
|
+
? {
|
|
535
|
+
id: `rewind:filter:${query}:${this.#targets[this.#selected]?.turnId ?? this.#selected}`,
|
|
536
|
+
start: composed.selStart,
|
|
537
|
+
end: composed.selEnd,
|
|
538
|
+
}
|
|
539
|
+
: undefined,
|
|
540
|
+
},
|
|
541
|
+
footer: [
|
|
542
|
+
`${theme.fg("accent", "filter:")} ${query}${theme.fg("accent", "▏")} ${count} ${theme.fg("dim", "↑/↓ step ←/→ user turns enter rewind esc show all")}`,
|
|
543
|
+
],
|
|
370
544
|
};
|
|
371
545
|
}
|
|
372
546
|
|
|
@@ -75,6 +75,7 @@ export const TAB_GROUPS: Record<SettingTab, readonly string[]> = {
|
|
|
75
75
|
"Todos",
|
|
76
76
|
"Grep & Browser",
|
|
77
77
|
"Computer",
|
|
78
|
+
"IDA Pro",
|
|
78
79
|
"GitHub",
|
|
79
80
|
"Output Limits",
|
|
80
81
|
"Execution",
|
|
@@ -131,6 +132,11 @@ export interface SettingsHost {
|
|
|
131
132
|
entries: readonly SettingsDisplayEntry[];
|
|
132
133
|
get(path: string): unknown;
|
|
133
134
|
set(path: string, value: unknown): void;
|
|
135
|
+
/**
|
|
136
|
+
* Removes the value from the global config: a project or other layer, or an environment
|
|
137
|
+
* variable, that configures the setting still applies; otherwise the default does.
|
|
138
|
+
*/
|
|
139
|
+
unset(path: string): void;
|
|
134
140
|
normalizeProviderLimits(value: unknown): Record<string, number>;
|
|
135
141
|
validateProviderLimits(value: unknown): Record<string, number>;
|
|
136
142
|
}
|
|
@@ -1100,9 +1100,10 @@ export class SettingsSelectorComponent implements Component {
|
|
|
1100
1100
|
this.#formatTextInputEditValue(def.path, this.#context.settings.get(def.path)),
|
|
1101
1101
|
def.secret,
|
|
1102
1102
|
value => {
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
this.#
|
|
1103
|
+
// An empty field removes the persisted value, so the default (or an
|
|
1104
|
+
// environment fallback) applies again instead of a pinned "".
|
|
1105
|
+
if (value === "") this.#context.settings.unset(def.path);
|
|
1106
|
+
else this.#setSettingValue(def.path, value);
|
|
1106
1107
|
this.#callbacks.onChange(def.path, this.#context.settings.get(def.path));
|
|
1107
1108
|
wrappedDone(this.#formatTextInputValue(def, this.#context.settings.get(def.path)));
|
|
1108
1109
|
},
|
package/src/prompt/composer.ts
CHANGED
|
@@ -91,6 +91,18 @@ export interface ComposerOptions {
|
|
|
91
91
|
readonly now?: () => number;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/** How {@link Composer.setRuntimeChildren} bills below-transcript roots against transcript retirement. */
|
|
95
|
+
export interface RuntimeChildrenOptions {
|
|
96
|
+
/**
|
|
97
|
+
* Below-transcript roots whose growth is momentary (inline dialogs, a tall
|
|
98
|
+
* multi-line editor). They are billed at their smallest height so expansion
|
|
99
|
+
* clips the live tail instead of retiring rows a later shrink could not
|
|
100
|
+
* reclaim (#11007). Every other root is billed at its current height, so
|
|
101
|
+
* settled rows it displaces retire to native scrollback.
|
|
102
|
+
*/
|
|
103
|
+
readonly transient?: readonly Component[];
|
|
104
|
+
}
|
|
105
|
+
|
|
94
106
|
/** Controls the first terminal paint for a composer that does not already own the terminal. */
|
|
95
107
|
export interface ComposerStartOptions {
|
|
96
108
|
readonly clearScrollback?: boolean;
|
|
@@ -253,15 +265,18 @@ export class Composer implements TerminalFrameProvider {
|
|
|
253
265
|
#retiredHeaderStart = 0;
|
|
254
266
|
#resizeRetiredHeaderStart: number | undefined;
|
|
255
267
|
#lastNormalRows = 0;
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
268
|
+
// Roots from `RuntimeChildrenOptions.transient`, and the smallest height
|
|
269
|
+
// they have rendered at since mount. Retirement bills transient roots at
|
|
270
|
+
// this floor, never their peak, so a dialog or tall editor that later
|
|
271
|
+
// shrinks never leaves committed transcript rows the live viewport cannot
|
|
272
|
+
// reclaim (#11007). Persistent roots (loader, todo/subagent HUDs) bill at
|
|
273
|
+
// their current height: they stay up for a whole turn, and billing them at
|
|
274
|
+
// an idle floor hid the settled rows they displaced until the turn ended.
|
|
275
|
+
// The floor is terminal-height independent — editor and dialog heights do
|
|
276
|
+
// not scale with rows — so it is retained across resizes rather than
|
|
277
|
+
// rediscovered from whatever chrome is expanded when the height changes.
|
|
278
|
+
#transientChrome: ReadonlySet<Component> = new Set();
|
|
279
|
+
#transientChromeFloor: number | undefined;
|
|
265
280
|
#lastInterruptAt = 0;
|
|
266
281
|
#started = false;
|
|
267
282
|
#stopped = false;
|
|
@@ -346,60 +361,24 @@ export class Composer implements TerminalFrameProvider {
|
|
|
346
361
|
const afterRoots = roots.slice(transcriptIndex + 1);
|
|
347
362
|
const after: string[] = [];
|
|
348
363
|
const afterSpans: ViewportClickSpan[] = [];
|
|
364
|
+
let transientRows = 0;
|
|
349
365
|
for (const root of afterRoots) {
|
|
350
366
|
const start = after.length;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
// `Container.render` is a pure concatenation, so child spans tile
|
|
354
|
-
// the root span exactly. Render those children once and share the
|
|
355
|
-
// rows for composition and measurement — a second render per frame
|
|
356
|
-
// would duplicate render-time side effects (image placement
|
|
357
|
-
// registration). Roots with a custom render keep the composed
|
|
358
|
-
// output as the source of truth and measure up to the last target.
|
|
359
|
-
const plainContainer = root instanceof Container && root.render === Container.prototype.render;
|
|
360
|
-
const targets = root instanceof Container ? root.children : [root];
|
|
361
|
-
const resolves = targets.map(rowTargetCandidates);
|
|
362
|
-
const lastTarget = resolves.findLastIndex(resolve => resolve !== undefined);
|
|
363
|
-
if (plainContainer) {
|
|
364
|
-
let offset = start;
|
|
365
|
-
for (let index = 0; index < targets.length; index++) {
|
|
366
|
-
const childLines = targets[index]!.render(width);
|
|
367
|
-
after.push(...childLines);
|
|
368
|
-
if (index > lastTarget) continue;
|
|
369
|
-
const resolve = resolves[index];
|
|
370
|
-
if (resolve !== undefined && childLines.length > 0) {
|
|
371
|
-
afterSpans.push({ start: offset, end: offset + childLines.length, candidates: resolve });
|
|
372
|
-
}
|
|
373
|
-
offset += childLines.length;
|
|
374
|
-
}
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
after.push(...root.render(width));
|
|
378
|
-
if (lastTarget === -1) continue;
|
|
379
|
-
let offset = start;
|
|
380
|
-
for (let index = 0; index <= lastTarget; index++) {
|
|
381
|
-
const childLines = targets[index] === root ? after.length - start : targets[index]!.render(width).length;
|
|
382
|
-
const resolve = resolves[index];
|
|
383
|
-
if (resolve !== undefined && childLines > 0) {
|
|
384
|
-
afterSpans.push({ start: offset, end: offset + childLines, candidates: resolve });
|
|
385
|
-
}
|
|
386
|
-
offset += childLines;
|
|
387
|
-
}
|
|
367
|
+
this.#renderBelowRoot(root, width, after, afterSpans);
|
|
368
|
+
if (this.#transientChrome.has(root)) transientRows += after.length - start;
|
|
388
369
|
}
|
|
389
370
|
// Offer history under capacity pressure only: blocks stay live (and keep
|
|
390
371
|
// reflowing to the current width) while the screen has room. A batch
|
|
391
372
|
// leaves the mutable viewport in the same frame it is appended, so its
|
|
392
373
|
// rows are never painted twice.
|
|
393
374
|
//
|
|
394
|
-
// Retirement
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
this.#
|
|
401
|
-
this.#retirementBelowFloor === undefined ? after.length : Math.min(this.#retirementBelowFloor, after.length);
|
|
402
|
-
const belowFloor = this.#retirementBelowFloor;
|
|
375
|
+
// Retirement bills transient roots at their floor, not their peak: a
|
|
376
|
+
// confirmation dialog or a tall multi-line editor clips the live tail for
|
|
377
|
+
// its lifetime, but must not permanently commit transcript rows to native
|
|
378
|
+
// history — otherwise a later shrink cannot refill the freed rows and the
|
|
379
|
+
// editor drifts up above a band of blank rows (#11007).
|
|
380
|
+
this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
|
|
381
|
+
const belowFloor = after.length - transientRows + this.#transientChromeFloor;
|
|
403
382
|
const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
|
|
404
383
|
const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
|
|
405
384
|
const headerRows = headerVisible ? this.#header.render(width) : [];
|
|
@@ -443,6 +422,52 @@ export class Composer implements TerminalFrameProvider {
|
|
|
443
422
|
return { history, viewport: this.#paintHoverBand(mutable, spans) };
|
|
444
423
|
}
|
|
445
424
|
|
|
425
|
+
/**
|
|
426
|
+
* Append one below-transcript root's rows to `after`, recording click spans
|
|
427
|
+
* for its row targets in `after` coordinates.
|
|
428
|
+
*
|
|
429
|
+
* Row targets usually nest one level down: chrome roots are plain
|
|
430
|
+
* containers (the HUD lives inside `subagentContainer`), and
|
|
431
|
+
* `Container.render` is a pure concatenation, so child spans tile the root
|
|
432
|
+
* span exactly. Render those children once and share the rows for
|
|
433
|
+
* composition and measurement — a second render per frame would duplicate
|
|
434
|
+
* render-time side effects (image placement registration). Roots with a
|
|
435
|
+
* custom render keep the composed output as the source of truth and measure
|
|
436
|
+
* up to the last target.
|
|
437
|
+
*/
|
|
438
|
+
#renderBelowRoot(root: Component, width: number, after: string[], spans: ViewportClickSpan[]): void {
|
|
439
|
+
const start = after.length;
|
|
440
|
+
const plainContainer = root instanceof Container && root.render === Container.prototype.render;
|
|
441
|
+
const targets = root instanceof Container ? root.children : [root];
|
|
442
|
+
const resolves = targets.map(rowTargetCandidates);
|
|
443
|
+
const lastTarget = resolves.findLastIndex(resolve => resolve !== undefined);
|
|
444
|
+
if (plainContainer) {
|
|
445
|
+
let offset = start;
|
|
446
|
+
for (let index = 0; index < targets.length; index++) {
|
|
447
|
+
const childLines = targets[index]!.render(width);
|
|
448
|
+
after.push(...childLines);
|
|
449
|
+
if (index > lastTarget) continue;
|
|
450
|
+
const resolve = resolves[index];
|
|
451
|
+
if (resolve !== undefined && childLines.length > 0) {
|
|
452
|
+
spans.push({ start: offset, end: offset + childLines.length, candidates: resolve });
|
|
453
|
+
}
|
|
454
|
+
offset += childLines.length;
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
after.push(...root.render(width));
|
|
459
|
+
if (lastTarget === -1) return;
|
|
460
|
+
let offset = start;
|
|
461
|
+
for (let index = 0; index <= lastTarget; index++) {
|
|
462
|
+
const childLines = targets[index] === root ? after.length - start : targets[index]!.render(width).length;
|
|
463
|
+
const resolve = resolves[index];
|
|
464
|
+
if (resolve !== undefined && childLines > 0) {
|
|
465
|
+
spans.push({ start: offset, end: offset + childLines, candidates: resolve });
|
|
466
|
+
}
|
|
467
|
+
offset += childLines;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
446
471
|
/**
|
|
447
472
|
* Band the hovered click target's current rows. Id-anchored (not
|
|
448
473
|
* line-anchored) so the band follows an agent whose rows shift while it
|
|
@@ -847,8 +872,10 @@ export class Composer implements TerminalFrameProvider {
|
|
|
847
872
|
}
|
|
848
873
|
|
|
849
874
|
/** Mount or replace session-aware root children while preserving the header and status hosts. */
|
|
850
|
-
setRuntimeChildren(children: readonly Component[]): void {
|
|
875
|
+
setRuntimeChildren(children: readonly Component[], options: RuntimeChildrenOptions = {}): void {
|
|
851
876
|
if (this.#stopped) return;
|
|
877
|
+
this.#transientChrome = new Set(options.transient);
|
|
878
|
+
this.#transientChromeFloor = undefined;
|
|
852
879
|
this.ui.removeChild(this.#statusHost);
|
|
853
880
|
if (this.#runtimeMounted) {
|
|
854
881
|
for (const child of this.#runtimeChildren) this.ui.removeChild(child);
|
|
@@ -5,6 +5,7 @@ import { BRACKETED_PASTE_END, BRACKETED_PASTE_START } from "../stdin-buffer";
|
|
|
5
5
|
import { Editor, type EditorTextDecorationContext, type EditorTheme } from "../components/editor";
|
|
6
6
|
import { addKeyAliases, canonicalKeyId, getKeybindings } from "../keybindings";
|
|
7
7
|
import { type KeyId, parseKey, parseKittySequence } from "../keys";
|
|
8
|
+
import { SpaceHoldGesture } from "../space-hold";
|
|
8
9
|
import { TUI } from "../tui";
|
|
9
10
|
import type { AppKeybinding } from "../app-keybindings";
|
|
10
11
|
import { allowsModelMentions, allowsSkillTokens, SKILL_TOKEN_RE } from "./skill-tokens";
|
|
@@ -117,34 +118,6 @@ const ABSOLUTE_PATH_PREFIX_REGEX = new RegExp(`^${ABSOLUTE_PATH_PREFIX_SOURCE}`)
|
|
|
117
118
|
*/
|
|
118
119
|
const INTERIOR_PATH_ANCHOR_REGEX = new RegExp(String.raw`(?<!\\)\s(?:${ABSOLUTE_PATH_PREFIX_SOURCE}|\.\.?[\\/])`);
|
|
119
120
|
|
|
120
|
-
/** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
|
|
121
|
-
* deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
|
|
122
|
-
export const SPACE_REPEAT_MAX_GAP_MS = 120;
|
|
123
|
-
/** Two consecutive inter-space gaps are "mechanical" (machine-driven auto-repeat) when both are
|
|
124
|
-
* within {@link SPACE_REPEAT_MAX_GAP_MS} and differ by no more than this — an absolute jitter floor
|
|
125
|
-
* or, for slower repeat rates, {@link SPACE_REPEAT_JITTER_RATIO} of the smaller gap. OS key-repeat
|
|
126
|
-
* is metronomic; a human smashing the bar is fast but irregular, so its deltas never stay this
|
|
127
|
-
* steady. */
|
|
128
|
-
export const SPACE_REPEAT_JITTER_MS = 18;
|
|
129
|
-
export const SPACE_REPEAT_JITTER_RATIO = 0.35;
|
|
130
|
-
/** Consecutive mechanical (fast + steady) deltas that confirm the space bar is held and start
|
|
131
|
-
* recording. Needs a sustained metronomic cadence, so jittery smashing and deliberate taps never
|
|
132
|
-
* reach it. */
|
|
133
|
-
export const SPACE_HOLD_MECHANICAL_RUN = 2;
|
|
134
|
-
/** Idle gap (ms) after the last repeated space that counts as the space bar being released, ending
|
|
135
|
-
* the push-to-talk recording. Must comfortably exceed the OS key-repeat interval. */
|
|
136
|
-
export const SPACE_HOLD_RELEASE_MS = 250;
|
|
137
|
-
|
|
138
|
-
/** Whether two consecutive inter-space gaps look machine-driven: both within the auto-repeat band
|
|
139
|
-
* and steady enough (small absolute or proportional difference). OS key-repeat is metronomic, so
|
|
140
|
-
* its successive deltas match closely; human smashing is fast but irregular and deliberate taps are
|
|
141
|
-
* too slow, so neither passes. */
|
|
142
|
-
function gapsAreMechanical(gap: number, prevGap: number): boolean {
|
|
143
|
-
if (gap > SPACE_REPEAT_MAX_GAP_MS || prevGap > SPACE_REPEAT_MAX_GAP_MS) return false;
|
|
144
|
-
const tolerance = Math.max(SPACE_REPEAT_JITTER_MS, Math.min(gap, prevGap) * SPACE_REPEAT_JITTER_RATIO);
|
|
145
|
-
return Math.abs(gap - prevGap) <= tolerance;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
121
|
function isPastedPathSeparator(char: string | undefined): boolean {
|
|
149
122
|
return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
150
123
|
}
|
|
@@ -902,15 +875,13 @@ export class CustomEditor extends Editor {
|
|
|
902
875
|
/** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
|
|
903
876
|
onLeftAtStart?: () => void;
|
|
904
877
|
|
|
905
|
-
/**
|
|
906
|
-
*
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
* normally; wired to `stt.enabled` so disabling STT restores plain space behavior. */
|
|
913
|
-
sttHoldEnabled?: () => boolean;
|
|
878
|
+
/** Space-bar push-to-talk; set its `handler` to enable it. It is a text-composition gesture, so it
|
|
879
|
+
* stays out of Vim's Normal/Visual modes (where the space bar is the `l` motion) and away from an
|
|
880
|
+
* open autocomplete menu. */
|
|
881
|
+
readonly spaceHold = new SpaceHoldGesture(
|
|
882
|
+
count => this.deleteBeforeCursor(count),
|
|
883
|
+
() => this.vimMode === "insert" && !this.isShowingAutocomplete(),
|
|
884
|
+
);
|
|
914
885
|
|
|
915
886
|
/** Custom key handlers from extensions and non-built-in app actions. */
|
|
916
887
|
#customKeyHandlers = new Map<KeyId, () => void>();
|
|
@@ -928,18 +899,6 @@ export class CustomEditor extends Editor {
|
|
|
928
899
|
/** Input chunks deferred behind an in-flight paste, drained in FIFO order once the paste
|
|
929
900
|
* count returns to zero. */
|
|
930
901
|
#pendingInput: string[] = [];
|
|
931
|
-
/** Spaces actually inserted in the current run; tracked back out when a hold is recognized. */
|
|
932
|
-
#spaceRunInserted = 0;
|
|
933
|
-
/** Consecutive "mechanical" deltas (fast + steady); a sustained run of these confirms a held bar. */
|
|
934
|
-
#mechanicalRun = 0;
|
|
935
|
-
/** Inter-space gap (ms) of the previous space pair, compared against the next to judge steadiness. */
|
|
936
|
-
#prevSpaceGap: number | undefined;
|
|
937
|
-
/** Monotonic timestamp (ms) of the last space, to measure the gap to the next one. */
|
|
938
|
-
#lastSpaceAt = Number.NEGATIVE_INFINITY;
|
|
939
|
-
/** True while a recognized space-hold push-to-talk recording is in progress. */
|
|
940
|
-
#spaceHoldActive = false;
|
|
941
|
-
/** Idle timer that fires `onSpaceHoldEnd` once repeated spaces stop arriving. */
|
|
942
|
-
#spaceHoldTimer: NodeJS.Timeout | undefined;
|
|
943
902
|
#actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
|
|
944
903
|
Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
|
|
945
904
|
);
|
|
@@ -1004,93 +963,6 @@ export class CustomEditor extends Editor {
|
|
|
1004
963
|
this.#rebuildCustomMatchKeys();
|
|
1005
964
|
}
|
|
1006
965
|
|
|
1007
|
-
#spaceHoldGestureEnabled(): boolean {
|
|
1008
|
-
// Push-to-talk is a text-composition gesture, so it stays out of Vim's Normal/Visual modes
|
|
1009
|
-
// where the space bar is the `l` motion.
|
|
1010
|
-
if (this.vimMode !== "insert") return false;
|
|
1011
|
-
return this.onSpaceHoldStart !== undefined && (this.sttHoldEnabled?.() ?? false) && !this.isShowingAutocomplete();
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/** Drive the space-hold push-to-talk state machine. Returns true when the gesture consumed the
|
|
1015
|
-
* input so it must not reach normal editing. A held space bar emits OS auto-repeat: a *steady*
|
|
1016
|
-
* stream of spaces at a fixed fast interval. We watch the inter-space deltas and only recognize a
|
|
1017
|
-
* hold once {@link SPACE_HOLD_MECHANICAL_RUN} consecutive deltas are "mechanical" — both
|
|
1018
|
-
* auto-repeat-fast and near-identical (see {@link gapsAreMechanical}). Smashing the bar is fast
|
|
1019
|
-
* but jittery and deliberate taps are too slow, so neither escalates and both keep typing real
|
|
1020
|
-
* spaces; the few spaces typed before a real hold is recognized are tracked back out. */
|
|
1021
|
-
#handleSpaceHold(data: string, canonical: string | undefined): boolean {
|
|
1022
|
-
const isSpace = canonical === "space";
|
|
1023
|
-
if (this.#spaceHoldActive) {
|
|
1024
|
-
if (isSpace) {
|
|
1025
|
-
// Auto-repeat while held: swallow it and keep the release timer alive.
|
|
1026
|
-
this.#armSpaceHoldReleaseTimer();
|
|
1027
|
-
return true;
|
|
1028
|
-
}
|
|
1029
|
-
// Any non-space means the bar was released — stop recording, then let the key through.
|
|
1030
|
-
this.#endSpaceHold();
|
|
1031
|
-
return false;
|
|
1032
|
-
}
|
|
1033
|
-
if (!isSpace) {
|
|
1034
|
-
this.#resetSpaceRun();
|
|
1035
|
-
return false;
|
|
1036
|
-
}
|
|
1037
|
-
if (!this.#spaceHoldGestureEnabled()) return false;
|
|
1038
|
-
const now = performance.now();
|
|
1039
|
-
const gap = now - this.#lastSpaceAt;
|
|
1040
|
-
const prevGap = this.#prevSpaceGap;
|
|
1041
|
-
this.#lastSpaceAt = now;
|
|
1042
|
-
this.#prevSpaceGap = gap;
|
|
1043
|
-
if (prevGap === undefined || !gapsAreMechanical(gap, prevGap)) {
|
|
1044
|
-
// First space, a deliberate tap, or jittery smashing: not a steady machine cadence yet, so
|
|
1045
|
-
// type a real space and reset the mechanical run.
|
|
1046
|
-
this.#mechanicalRun = 0;
|
|
1047
|
-
this.#forwardInput(data);
|
|
1048
|
-
this.#spaceRunInserted++;
|
|
1049
|
-
return true;
|
|
1050
|
-
}
|
|
1051
|
-
// Steady fast repeat: swallow it. Once the cadence has held for SPACE_HOLD_MECHANICAL_RUN
|
|
1052
|
-
// deltas it's a held bar — track back the few pre-burst spaces already typed and start.
|
|
1053
|
-
if (++this.#mechanicalRun >= SPACE_HOLD_MECHANICAL_RUN) {
|
|
1054
|
-
this.deleteBeforeCursor(this.#spaceRunInserted);
|
|
1055
|
-
this.#resetSpaceRun();
|
|
1056
|
-
this.#beginSpaceHold();
|
|
1057
|
-
}
|
|
1058
|
-
return true;
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
#resetSpaceRun(): void {
|
|
1062
|
-
this.#spaceRunInserted = 0;
|
|
1063
|
-
this.#mechanicalRun = 0;
|
|
1064
|
-
this.#prevSpaceGap = undefined;
|
|
1065
|
-
this.#lastSpaceAt = Number.NEGATIVE_INFINITY;
|
|
1066
|
-
}
|
|
1067
|
-
|
|
1068
|
-
#beginSpaceHold(): void {
|
|
1069
|
-
this.#spaceHoldActive = true;
|
|
1070
|
-
this.#armSpaceHoldReleaseTimer();
|
|
1071
|
-
this.onSpaceHoldStart?.();
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
#armSpaceHoldReleaseTimer(): void {
|
|
1075
|
-
if (this.#spaceHoldTimer) clearTimeout(this.#spaceHoldTimer);
|
|
1076
|
-
this.#spaceHoldTimer = setTimeout(() => {
|
|
1077
|
-
this.#spaceHoldTimer = undefined;
|
|
1078
|
-
this.#endSpaceHold();
|
|
1079
|
-
}, SPACE_HOLD_RELEASE_MS);
|
|
1080
|
-
this.#spaceHoldTimer.unref?.();
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
#endSpaceHold(): void {
|
|
1084
|
-
if (!this.#spaceHoldActive) return;
|
|
1085
|
-
this.#spaceHoldActive = false;
|
|
1086
|
-
this.#resetSpaceRun();
|
|
1087
|
-
if (this.#spaceHoldTimer) {
|
|
1088
|
-
clearTimeout(this.#spaceHoldTimer);
|
|
1089
|
-
this.#spaceHoldTimer = undefined;
|
|
1090
|
-
}
|
|
1091
|
-
this.onSpaceHoldEnd?.();
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
966
|
/** Decrement {@link #pasteInFlight} once an async paste settles and, when the count returns
|
|
1095
967
|
* to zero, drain {@link #pendingInput} through `handleInput` so requeueing still works if a
|
|
1096
968
|
* drained chunk triggers another async paste. Bound member so it can be passed straight to
|
|
@@ -1188,7 +1060,13 @@ export class CustomEditor extends Editor {
|
|
|
1188
1060
|
}
|
|
1189
1061
|
|
|
1190
1062
|
// Space-hold push-to-talk: a sustained space bar starts/stops STT instead of typing spaces.
|
|
1191
|
-
|
|
1063
|
+
switch (this.spaceHold.process(canonical === "space")) {
|
|
1064
|
+
case "type":
|
|
1065
|
+
this.#forwardInput(data);
|
|
1066
|
+
return;
|
|
1067
|
+
case "swallow":
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1192
1070
|
|
|
1193
1071
|
// One union probe decides whether any per-action interception below can
|
|
1194
1072
|
// match — plain typing then skips the ~20 per-action set lookups per key.
|