Repository navigation
Expand file tree
/
Copy pathgentle-shell.ts
More file actions
2545 lines (2470 loc) · 127 KB
/
Copy pathgentle-shell.ts
File metadata and controls
2545 lines (2470 loc) · 127 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { CustomEditor, keyHint, type ExtensionAPI, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
import { Editor, decodeKittyPrintable, isKeyRelease, matchesKey, parseKey, truncateToWidth, visibleWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
import { execFile, spawnSync } from "node:child_process";
import { realpathSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { profilesFilePath, profileRoleEntries, readProfilesFileResult } from "../lib/agent-profiles.ts";
import { readSessionProfileBinding } from "../lib/session-profile-binding.ts";
import { resolveProfilePin } from "../lib/agent-profile-pin.ts";
import * as os from "node:os";
import { dirname, join, resolve } from "node:path";
import { buildShellHeaderModel, renderShellBar, renderShellBelowInputFloat, renderShellBottomOnlyBar, renderShellHeaderChrome, renderShellSidebarBar, keepNativeWorkingRow, shellEnabled, shellHeaderUsageHit, shellJobsCount, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver, type WorktreeIdentity } from "../lib/session-worktree-registry.ts";
import { CARD_STYLE, CARD_TONE, cardStyle, renderCard, setCardStyle, type Card, type CardTheme } from "../lib/shell-card.ts";
import { resolveCardStyle, writeCardStyle } from "../lib/card-style-policy.ts";
import { CARD_CONTENT, resolveCardContent, setCardContent, writeCardContent } from "../lib/card-content-policy.ts";
import { CommandPalette, commandsKey, type CommandPaletteResult } from "../lib/command-palette.ts";
import { buildCommandPaletteGroups } from "../lib/command-palette-catalog.ts";
import { discoverYoloUiAdapter, YOLO_DISPLAY, type YoloDisplay, type YoloUiAdapter } from "../lib/yolo-session-policy.ts";
import { VisualCustomizeView, type CustomizeCategory, type CustomizeRow, type ProfileActions } from "../lib/visual-customize-view.ts";
import { deleteVisualProfile, getVisualProfile, listVisualProfiles, resetVisualProfiles, saveVisualProfile } from "../lib/visual-profiles.ts";
import { sourcePalettePreview } from "../lib/theme-customization.ts";
import { DEFAULT_VISUAL_SETTINGS, DENSITY, HEADER_PLACEMENT, STATUS_PLACEMENT, VISUAL_SECTION_KEYS, resolveVisualSettings, writeVisualSettings } from "../lib/visual-customization-policy.ts";
import { BANNER_COLORS, DEFAULT_BANNER_CONFIG, readBannerConfig, readBannerConfigForEdit, writeBannerConfig } from "./startup-banner.ts";
import { agentsViewKey } from "../lib/agents-keys.ts";
import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
import { DOUBLE_ESC_CANCEL_HINT, floatPromptRow, framePromptLines, resolvePromptLayout, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
import { oddPhaseRegistry } from "../lib/odd-phase.ts";
import { inferOddPhase } from "../lib/odd-phase-inference.ts";
import { isInteractiveMode } from "../lib/rpc-host.ts";
import { gentlePiConfigHome } from "../lib/agent-home.ts";
import { isNanAdaptiveReasoningModel } from "../lib/nan-provider.ts";
import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
import { resolveVimPolicy, writeVimPolicy, type VimPolicy } from "../lib/vim-policy.ts";
import { resolveHistoryCapture, writeHistoryCapturePolicy } from "../lib/history-capture-policy.ts";
import { createRequire } from "node:module";
import { createVimEditorAdapter, isAuditedPiEditorVersion } from "../lib/vim-editor-adapter.ts";
import { VimNormalEngine } from "../lib/vim-normal-engine.ts";
import { VimOperatorEngine, type OperatorResult } from "../lib/vim-operator-engine.ts";
import { VimVisualEngine } from "../lib/vim-visual-engine.ts";
// Canonical entrypoint and index patterns across POSIX and Windows separators.
// Exported for cross-platform unit testing of candidate path resolution.
export const VIM_CLI_ENTRY_PATTERN = /(?:^|[\\/])dist[\\/]bundle[\\/]cli\.js$/;
export const VIM_AGENT_INDEX_PATTERN = /(?:^|[\\/])dist[\\/]index\.js$/;
// Candidate paths provide only package roots, never version authority. Both
// constructors must come from that same canonical agent/TUI pair before its
// metadata can admit private editing (jiti may alias imports to host modules).
export function resolveVimRuntime(entry = process.argv[1], customClass: typeof CustomEditor = CustomEditor):
{ version: string; editorClass: typeof Editor } | undefined {
const editorPrototype = Object.getPrototypeOf(customClass.prototype) as typeof Editor.prototype | undefined;
const editorClass = editorPrototype?.constructor as typeof Editor | undefined;
const candidates: string[] = [];
// Audited CLIs (AUDITED_PI_EDITOR_VERSIONS) use the bundled virtual module graph.
// Its public index exports the very CustomEditor class supplied to extensions by that graph.
try {
if (entry) {
const cli = realpathSync(entry);
if (VIM_CLI_ENTRY_PATTERN.test(cli)) {
const root = resolve(dirname(cli), "../..");
const bundlePath = resolve(root, "dist", "bundle", "index.js");
if (realpathSync(bundlePath) === bundlePath) {
const requireFromBundle = createRequire(bundlePath);
const bundled = requireFromBundle(bundlePath) as { CustomEditor?: typeof CustomEditor; VERSION?: string };
const metadata = requireFromBundle(resolve(root, "package.json")) as { name?: string; version?: string };
if (metadata.name === "@earendil-works/pi-coding-agent" && isAuditedPiEditorVersion(metadata.version) &&
bundled.VERSION === metadata.version && bundled.CustomEditor === customClass &&
typeof editorClass === "function" && editorClass.name === "Editor" &&
editorPrototype === editorClass.prototype &&
Object.getPrototypeOf(customClass) === editorClass) {
return { version: metadata.version, editorClass };
}
}
}
}
} catch { /* Unknown bundled host: do not authorize private editing. */ }
try {
if (entry) {
const cli = realpathSync(entry);
if (VIM_CLI_ENTRY_PATTERN.test(cli)) candidates.push(resolve(dirname(cli), "../.."));
}
} catch { /* The CLI is only a candidate, not proof. */ }
try {
const localIndex = realpathSync(fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent")));
if (VIM_AGENT_INDEX_PATTERN.test(localIndex)) candidates.push(resolve(dirname(localIndex), ".."));
} catch { /* No local candidate; do not trust extension-relative metadata. */ }
for (const root of new Set(candidates)) {
try {
const agentIndex = realpathSync(resolve(root, "dist", "index.js"));
if (agentIndex !== resolve(root, "dist", "index.js")) continue;
const requireFromRuntime = createRequire(agentIndex);
const agent = requireFromRuntime(agentIndex) as { CustomEditor?: typeof CustomEditor };
const agentMetadata = requireFromRuntime(resolve(root, "package.json")) as { version?: string; name?: string };
const tuiPath = realpathSync(requireFromRuntime.resolve("@earendil-works/pi-tui"));
const tui = requireFromRuntime(tuiPath) as { Editor?: typeof Editor };
const tuiRoot = resolve(dirname(tuiPath), "..");
const tuiMetadata = requireFromRuntime(resolve(tuiRoot, "package.json")) as { version?: string; name?: string };
if (agent.CustomEditor === customClass && tui.Editor === editorClass &&
agentMetadata.name === "@earendil-works/pi-coding-agent" && tuiMetadata.name === "@earendil-works/pi-tui" &&
isAuditedPiEditorVersion(agentMetadata.version) &&
agentMetadata.version === tuiMetadata.version) return { version: tuiMetadata.version, editorClass };
} catch { /* Unknown package or constructor: ordinary editing stays active. */ }
}
return undefined;
}
import {
DOUBLE_ESC_CANCEL_WINDOW_MS,
resolveDoubleEscCancelPolicy,
writeDoubleEscCancelPolicy,
type DoubleEscCancelPolicy,
type DoubleEscCancelResolution,
} from "../lib/double-esc-cancel-policy.ts";
import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, usageScopeProviders, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
import { UsageView } from "../lib/shell-usage-view.ts";
import { sidebarHeader, sidebarPart, sidebarState, VISUAL_SETTINGS_CHANGED, type SidebarRail } from "../lib/shell-sidebar.ts";
import { installSidebar, invalidateSidebar, narrowStatusOwner, SIDEBAR_BREAKPOINT, STATUS_OWNER } from "../lib/shell-sidebar-layout.ts";
import { SessionChanges, SESSION_CHANGE_EVENT } from "../lib/session-changes.ts";
import { REVIEW_SIDEBAR_EVENT, isReviewSidebarSnapshot, type ReviewSidebarSnapshot } from "../lib/review-sidebar-state.ts";
import { JOBS_SIDEBAR_EVENT, JOBS_STATUS_KEY, isJobsSidebarSnapshot, type RunningJobDisplay } from "../lib/jobs-sidebar-state.ts";
import { installSessionChangeCapture } from "../lib/session-change-capture.ts";
import { SelectionEngine } from "../lib/selection-engine.ts";
import { withOverlayRepaint } from "../lib/overlay-repaint.ts";
import { buildNotificationRows } from "../lib/notification-customize.ts";
// Gentle Shell: the visual layer gentle-pi puts on top of pi. It installs the
// status bar, the petal prompt, the working-tree changes widget and overlay,
// the subscription usage view, and the cards Gentle notices are drawn with.
export interface ShellFooterData {
getGitBranch(): string | null;
getExtensionStatuses(): ReadonlyMap<string, string>;
getAvailableProviderCount(): number;
onBranchChange(callback: () => void): () => void;
}
interface ShellRenderHost {
requestRender(): void;
invalidateSidebar?(): void;
}
interface ShellBarComponent {
render(width: number): string[];
invalidate(): void;
dispose(): void;
}
interface BuildOptions {
jobs?: RunningJobDisplay[];
profile?: string;
home?: string;
dirty?: number;
usage?: ProviderUsage;
}
export type DevBinaryNotice = { state: "active"; path: string; sha256: string } | { state: "invalid"; reason: string };
export interface ShellDeps {
activeProfile(): string | undefined;
fetch: typeof fetch;
now(): number;
devBinary(): DevBinaryNotice | undefined;
resolveWorktree: WorktreeResolver;
gitRunner(cwd: string): GitRunner;
vimRuntimeVersion?(): string | undefined;
}
export type ActiveProfileReader = (() => string | undefined) & {
bind(cwd: string, resolveWorktree: WorktreeResolver, sessionId?: string): boolean;
refresh(): boolean;
reset(): void;
};
// Unbound reads retain the global file-identity cache, including atomic replacements.
// Bound reads are snapshots: no filesystem or Git work is done during a frame.
export function createActiveProfileReader(env: NodeJS.ProcessEnv = process.env): ActiveProfileReader {
const path = profilesFilePath(env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"));
let fingerprint: string | undefined;
let name: string | undefined;
let bound = false;
let cwd: string | undefined;
let identity: WorktreeIdentity | undefined;
let sessionId: string | undefined;
let effective: string | undefined;
const global = () => {
try {
const stat = statSync(path, { bigint: true });
const next = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
if (next !== fingerprint) {
const result = readProfilesFileResult(path);
name = result.status === "valid" ? result.file.active : undefined;
fingerprint = next;
}
return name;
} catch {
fingerprint = undefined;
name = undefined;
return undefined;
}
};
const reader = (() => bound ? effective : global()) as ActiveProfileReader;
reader.refresh = () => {
if (!bound) return false;
// gentle-shell#1064 slice 1: a parent-session profile binding outranks
// both the pin layers and the global active profile, with the same
// "name (scope)" spelling the pin uses. The shared precedence rule lives
// in one place: session → p (local pin) → P (repo declaration) → global.
const session = sessionId === undefined ? undefined : readSessionProfileBinding(sessionId);
if (session !== undefined) {
const next = `${session.name} (session)`;
const changed = next !== effective;
effective = next;
return changed;
}
const pin = identity && cwd ? resolveProfilePin({
cwd,
configHome: env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai"),
resolveWorktree: () => identity!,
}) : undefined;
const next = pin ? `${pin.profile} (${pin.source})` : global();
const changed = next !== effective;
effective = next;
return changed;
};
reader.bind = (nextCwd, resolveWorktree, nextSessionId) => {
reader.reset();
cwd = nextCwd;
sessionId = nextSessionId;
try { identity = resolveWorktree(nextCwd, nextCwd); } catch { identity = undefined; }
bound = true;
return reader.refresh();
};
reader.reset = () => { bound = false; cwd = undefined; identity = undefined; sessionId = undefined; effective = undefined; };
return reader;
}
function ambientDevBinary(): DevBinaryNotice | undefined {
try {
const override = resolveGentleAiDevBinaryOverride();
return override ? { state: "active", path: override.path, sha256: override.sha256 } : undefined;
} catch (error) {
if (error instanceof GentleAiDevBinaryOverrideError) return { state: "invalid", reason: error.message };
return undefined;
}
}
const defaultShellDeps: Omit<ShellDeps, "activeProfile"> = { fetch: (input, init) => globalThis.fetch(input, init), now: () => Date.now(), devBinary: ambientDevBinary, resolveWorktree: resolveSessionWorktree, gitRunner: shellGitRunner };
interface AssistantUsageEntry {
type: string;
message?: {
role?: string;
usage?: {
cost?: { total?: number };
};
};
}
function shortenHome(cwd: string, home: string | undefined): string {
if (home && cwd.startsWith(home)) return `~${cwd.slice(home.length)}`;
return cwd;
}
function sessionCost(ctx: ExtensionContext): number {
let total = 0;
for (const entry of ctx.sessionManager.getEntries() as AssistantUsageEntry[]) {
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
total += entry.message.usage?.cost?.total ?? 0;
}
return total;
}
type ShellSessionStats = { costTotal: number; usage: ReturnType<ExtensionContext["getContextUsage"]> };
const shellSessionStats = new WeakMap<object, {
sessionId: string | undefined;
leafId: string | null;
entryCount: number | undefined;
model: ExtensionContext["model"];
contextWindow: number | undefined;
stats: ShellSessionStats;
}>();
function sessionStats(ctx: ExtensionContext): ShellSessionStats {
const manager = ctx.sessionManager as ExtensionContext["sessionManager"] & { getEntryCount?(): number };
const compute = (): ShellSessionStats => ({ costTotal: sessionCost(ctx), usage: ctx.getContextUsage() });
// Append-only session entries move the leaf, including compaction and branch
// switches. A virtual model's routed physical model comes from the latest
// response, which is appended as an entry, so the leaf covers it as well.
// Older/test hosts without that identity must stay uncached.
if (typeof manager.getLeafId !== "function") return compute();
const sessionId = manager.getSessionId();
const leafId = manager.getLeafId();
const entryCount = manager.getEntryCount?.();
const model = ctx.model;
const contextWindow = model?.contextWindow;
const cached = shellSessionStats.get(manager);
if (cached && cached.sessionId === sessionId && cached.leafId === leafId &&
cached.entryCount === entryCount && cached.model === model && cached.contextWindow === contextWindow) return cached.stats;
const stats = compute();
shellSessionStats.set(manager, { sessionId, leafId, entryCount, model, contextWindow, stats });
return stats;
}
export function buildShellBarModel(
pi: ExtensionAPI,
ctx: ExtensionContext,
footerData: ShellFooterData,
options: BuildOptions = {},
): ShellBarModel {
const home = options.home ?? os.homedir();
const { usage, costTotal } = sessionStats(ctx);
const model = ctx.model;
const adaptiveReasoning = isNanAdaptiveReasoningModel(model);
const statuses = Array.from(footerData.getExtensionStatuses().entries())
.filter(([key]) => options.jobs === undefined || key !== JOBS_STATUS_KEY)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, text]) => text);
return {
cwd: shortenHome(ctx.sessionManager.getCwd(), home),
profile: options.profile,
branch: footerData.getGitBranch(),
dirty: options.dirty,
sessionName: ctx.sessionManager.getSessionName(),
modelId: model?.id ?? "no-model",
effort: model?.reasoning ? (adaptiveReasoning ? "auto" : pi.getThinkingLevel()) : undefined,
contextPercent: usage?.percent ?? null,
contextWindow: usage?.contextWindow ?? model?.contextWindow ?? 0,
costTotal,
subscription: model ? ctx.modelRegistry.isUsingOAuth(model) : false,
usage: options.usage,
...(options.jobs !== undefined ? { jobs: options.jobs } : {}),
statuses,
};
}
export function createShellBarComponent(
pi: ExtensionAPI,
ctx: ExtensionContext,
host: ShellRenderHost,
theme: ShellBarTheme,
footerData: ShellFooterData,
dirty: () => number | undefined = () => undefined,
usage: () => ProviderUsage | undefined = () => undefined,
presentation?: () => ReturnType<typeof resolveVisualSettings>["settings"],
): ShellBarComponent {
const unsubscribe = footerData.onBranchChange(() => {
host.invalidateSidebar?.();
host.requestRender();
});
return {
render(width: number) {
return renderShellBar(buildShellBarModel(pi, ctx, footerData, { dirty: dirty(), usage: usage() }), theme, width, presentation?.());
},
invalidate() {},
dispose() {
unsubscribe();
},
};
}
interface PromptEditorDeps {
fg: (color: string, text: string) => string;
bg?: (color: string, text: string) => string;
bold: (text: string) => string;
requestRender(): void;
pending(): boolean;
now(): number;
/** Read fresh on every keypress: the command handler updates this in-memory, the editor never re-reads the file. */
doubleEscCancelEnabled(): boolean;
/** Hand off text reconstructed from Pi's Esc-abort restore so it is sent as the next turn instead of sitting in the editor. */
dispatchQueuedText(text: string): void;
/** Override only for isolated adapter compatibility tests; production reads installed metadata. */
tuiVersion?: () => string | undefined;
/** Report a single warning if this editor cannot safely provide modal editing. */
notifyCompatibility?: (message: string) => void;
/**
* Read fresh on every render while WORKING: the explicit, orchestrator-reported
* ODD phase label for the current session, or undefined to fall back to the
* generic "working…" label. Never applied to IDLE or QUEUED.
*/
workingLabel?: () => string | undefined;
}
const PROMPT_FRAME_ROLE = "border";
// Matches Pi's own idle double-Esc window (empty editor -> /tree or /fork);
// this is the same muscle memory applied to clearing a non-empty draft.
const IDLE_ESC_CLEAR_WINDOW_MS = 500;
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
// Bound our framing buffer even when a terminal never sends the closing marker.
const MAX_BRACKETED_PASTE_CHARS = 1024 * 1024;
/**
* Pi's own Esc-abort handler (`restoreQueuedMessagesToEditor({ abort: true
* })`) rebuilds the editor text as
* `[queuedText, currentText].filter((t) => t.trim()).join("\n\n")`, where
* `currentText` is the draft captured just before the abort. Reverse that
* join to recover the queued text alone, so the draft can be restored by
* itself and the queued text dispatched as the next turn. `draft` is empty
* (including whitespace-only) whenever `.trim() === ""`, matching the
* `filter` predicate above exactly.
*
* Returns `""` only for the genuine no-queue case (`combined === draft`, or
* both empty). Returns `undefined` when `combined` does not match Pi's join
* shape at all — a future Pi change, or anything else that touched the
* editor during the abort. That distinction matters to the caller: an empty
* queue means "nothing to restore," while an unrecognized shape means "do
* not touch what Pi already wrote," so a mismatch is never silently treated
* as an empty queue.
*/
export function extractQueuedText(combined: string, draft: string): string | undefined {
if (combined === draft) return "";
if (draft.trim() === "") return combined;
const suffix = `\n\n${draft}`;
return combined.endsWith(suffix) ? combined.slice(0, combined.length - suffix.length) : undefined;
}
export class GentlePromptEditor extends CustomEditor {
private promptState: PromptState = PROMPT_STATE.IDLE;
private tick = 0;
private animationPolicy: AnimationPolicy = "quality";
private vimPolicy: VimPolicy = "off";
private vimRejected = false;
private vimNormal = false;
private compatibilityNotified = false;
private readonly vimEngine = new VimNormalEngine();
private readonly vimOperator = new VimOperatorEngine();
private vimInsertIntent?: { operator?: OperatorResult; command?: string };
private readonly vimVisual = new VimVisualEngine();
private pasteFrame: string | undefined;
private pasteOverflow = false;
private pasteTail = "";
private visualReplacePending = false;
private visualObjectPending: "i" | "a" | undefined;
private visualAnchor: { line: number; col: number } | undefined;
private pulse: NodeJS.Timeout | undefined;
private readonly deps: PromptEditorDeps;
private floatMouseLayout?: ReturnType<typeof resolvePromptLayout>;
// Native selection engine (shift+home/end, alt+a, replace-on-key): ported
// from pi-select-del so the petal prompt owns the feature without factory
// composition. Constructed with `this`; the internals probe degrades to
// passthrough on pi drift, costing only the selection features.
private readonly selectionEngine: SelectionEngine;
// CustomEditor keeps its own `keybindings` private, so this class holds
// its own reference to run the same app.interrupt match before deciding
// whether to swallow the keystroke.
private readonly keybindingsManager: KeybindingsManager;
private pendingEscapeCancelDeadline: number | undefined;
private pendingIdleClearDeadline: number | undefined;
// Snapshot of the draft at the first Esc; the second Esc only clears when
// the text is still exactly this, so an edit in between never gets
// silently discarded (issue #1218 review).
private pendingIdleClearText: string | undefined;
constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, deps: PromptEditorDeps) {
super(tui, theme, keybindings);
this.selectionEngine = new SelectionEngine(this);
this.deps = deps;
this.keybindingsManager = keybindings;
}
setAnimationPolicy(policy: AnimationPolicy): void {
if (this.animationPolicy === policy) return;
this.animationPolicy = policy;
this.stopPulse();
if (this.promptState === PROMPT_STATE.WORKING) this.startPulse();
this.deps.requestRender();
}
get effectiveVimPolicy(): VimPolicy { return this.vimPolicy; }
setVimPolicy(policy: VimPolicy): void {
this.vimRejected = false;
if (policy === "on") {
try { this.vimAdapter(); }
catch {
this.vimRejected = true;
if (!this.compatibilityNotified) {
this.compatibilityNotified = true;
this.deps.notifyCompatibility?.("Gentle Vim unsupported by this Pi editor layout/version; ordinary editing remains active.");
}
policy = "off";
}
}
if (this.vimPolicy === policy) return;
this.pasteFrame = undefined;
this.pasteOverflow = false;
this.pasteTail = "";
if (this.vimPolicy === "on" && !this.disablingUnsupportedVim) {
try { this.vimAdapter().endInsertSession(); } catch { /* Unsupported editor cannot group inserts. */ }
}
this.vimInsertIntent = undefined;
this.vimPolicy = policy;
this.vimNormal = false;
this.vimOperator.forgetRepeat();
this.vimOperator.cancel();
this.visualAnchor = undefined;
this.vimVisual.cancel();
this.visualReplacePending = false;
this.visualObjectPending = undefined;
this.deps.requestRender();
}
private notifyUnsafeVisual(stage: "range" | "readRange" | "beginInsertSession" | "replace" | "move" | "operation", error?: unknown): void {
// Only fixed reason codes reach UI; never include a draft, path, or raw host error.
const message = error instanceof Error ? error.message : "";
const reason = /duplicate registered paste marker/i.test(message) ? "duplicate-marker" :
/paste marker/i.test(message) ? "paste-marker" :
/unsupported pi editor layout/i.test(message) ? "unsupported-layout" :
/valid grapheme or paste boundary|invalid cursor boundary/i.test(message) ? "invalid-boundary" :
/invalid selection range|invalid operator range/i.test(message) ? "invalid-range" :
/insert session already active/i.test(message) ? "session-active" :
error === undefined ? "no-range" : "unexpected";
this.deps.notifyCompatibility?.((reason === "paste-marker"
? "Vim selection contains a registered paste marker; edit it in INSERT mode instead."
: "Vim selection cannot be edited safely; try a different selection.") + ` [${stage}:${reason}]`);
}
private disablingUnsupportedVim = false;
private vimAdapter(): ReturnType<typeof createVimEditorAdapter> {
const runtime = resolveVimRuntime();
try {
return createVimEditorAdapter(this, this.deps.tuiVersion?.() ?? runtime?.version ?? "unknown", runtime?.editorClass ?? Editor, runtime?.version);
} catch (error) {
if (this.vimPolicy === "on" && !this.disablingUnsupportedVim) {
this.disablingUnsupportedVim = true;
try { this.setVimPolicy("off"); this.vimRejected = true; } finally { this.disablingUnsupportedVim = false; }
if (!this.compatibilityNotified) {
this.compatibilityNotified = true;
this.deps.notifyCompatibility?.("Gentle Vim unsupported by this Pi editor layout/version; ordinary editing remains active.");
}
}
throw error;
}
}
private enterVimInsert(resetEngine = true, cancelOperator = true, beginSession = true): void {
if (this.vimNormal && beginSession) {
try { this.vimAdapter().beginInsertSession(); } catch { /* Unknown layout: normal edits remain disabled. */ }
}
this.vimNormal = false;
if (cancelOperator) this.vimOperator.cancel();
if (resetEngine) this.vimEngine.reset();
this.visualAnchor = undefined;
this.vimVisual.cancel();
this.visualReplacePending = false;
this.visualObjectPending = undefined;
this.deps.requestRender();
}
private startPulse(): void {
if (this.animationPolicy === "potato") return;
this.pulse = setInterval(() => {
this.tick += 1;
this.deps.requestRender();
}, this.animationPolicy === "performance" ? 1000 : SHELL_PULSE_MS);
this.pulse.unref();
}
setWorking(working: boolean): void {
this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
this.stopPulse();
if (!working) {
this.pendingEscapeCancelDeadline = undefined;
} else {
this.pendingIdleClearDeadline = undefined;
this.pendingIdleClearText = undefined;
this.startPulse();
}
this.deps.requestRender();
}
/**
* Swallow the first Esc while working (issue #1163), opt-in via
* doubleEscCancelEnabled(). `pi.registerShortcut("escape")` is not viable
* here: Pi reserves app.interrupt and skips colliding extension
* shortcuts, so this has to sit in front of CustomEditor's own
* handleInput instead. The Esc that actually aborts the turn (the single
* Esc when double-esc-cancel is off, or the confirming second Esc when
* it is on) always goes through abortAndDispatchQueued so the queued
* text Pi would otherwise dump back into the editor is sent as the next
* turn instead (issue #1218). Idle double-Esc (tree/fork), bash-mode
* Esc, and autocomplete cancel are all decided by CustomEditor/onEscape
* and never reach this branch.
*/
override handleInput(data: string): void {
// Selection and Vim handlers can consume input before the native chain;
// any intervening key invalidates an idle Esc confirmation.
if (this.pendingIdleClearDeadline !== undefined && !this.keybindingsManager.matches(data, "app.interrupt")) {
this.pendingIdleClearDeadline = undefined;
this.pendingIdleClearText = undefined;
}
// Vim owns its modal keys and paste frames. Ordinary editing retains
// native selection before the prompt's existing input chain, but a
// registered extension shortcut still owns its chord (gentle-shell#1565).
if (this.vimPolicy === "on") this.handleInputNative(data);
else this.selectionEngine.handleInput(data, (d) => this.handleInputNative(d), (d) => this.onExtensionShortcut?.(d) === true);
}
private handleInputNative(data: string): void {
if ((this.vimPolicy === "on" || this.vimRejected) && this.handleVimPasteFrame(data)) return;
if (this.vimPolicy === "on" && isKeyRelease(data)) return;
if (this.vimPolicy === "on" && this.vimNormal && matchesKey(data, "escape") && this.vimVisual.active) {
if (this.isShowingAutocomplete()) {
try { this.vimAdapter().dismissAutocomplete(); } catch { return; }
}
this.vimVisual.cancel(); this.visualAnchor = undefined;
this.visualReplacePending = false; this.visualObjectPending = undefined;
this.vimOperator.cancel(); this.vimEngine.cancelPendingFind();
this.deps.requestRender();
return;
}
if (this.vimPolicy === "on" && this.vimNormal && matchesKey(data, "escape") &&
(this.vimOperator.cancel() || this.vimEngine.cancelPendingFind())) {
this.deps.requestRender();
return;
}
// Pi owns autocomplete and shell input except while a VISUAL command
// owns the key. Defer that handoff until after shortcut priority.
const bashMode = this.getText().trimStart().startsWith("!");
if (this.vimPolicy === "on" && this.vimNormal && ((this.isShowingAutocomplete() && !this.vimVisual.active) ||
(bashMode && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")))) {
this.enterVimInsert();
}
if (this.vimPolicy === "on" && !this.vimNormal && !this.isShowingAutocomplete() &&
(this.keybindingsManager.matches(data, "app.interrupt") || matchesKey(data, "escape"))) {
try {
const inserted = this.vimAdapter().endInsertSession();
if (matchesKey(data, "escape") && inserted && this.vimInsertIntent?.operator)
this.vimOperator.rememberInsert(this.vimInsertIntent.operator, inserted);
else if (matchesKey(data, "escape") && inserted && this.vimInsertIntent?.command)
this.vimOperator.rememberLiteralInsert(this.vimInsertIntent.command, inserted);
} catch { /* Unknown layout: no grouping or repeat. */ }
this.vimInsertIntent = undefined;
this.vimNormal = true;
this.visualAnchor = undefined;
this.vimVisual.cancel();
this.pendingEscapeCancelDeadline = undefined;
this.pendingIdleClearDeadline = undefined;
this.pendingIdleClearText = undefined;
this.deps.requestRender();
return;
}
if (this.vimPolicy === "on" && (!this.isShowingAutocomplete() || this.vimVisual.active) && !bashMode) {
// CustomEditor gives extension, clipboard and app shortcuts priority
// over editor input. Preserve that order even when a shortcut shares
// a modal command key, and transfer ownership before its callback.
if (this.vimNormal && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")) {
if (this.onExtensionShortcut) {
const draft = this.getText();
const anchor = this.visualAnchor;
const visualMode = this.vimVisual.kind;
const replacePending = this.visualReplacePending;
const objectPending = this.visualObjectPending;
const restoreMode = () => {
this.vimNormal = true;
this.visualAnchor = anchor;
this.visualReplacePending = replacePending;
this.visualObjectPending = objectPending;
if (anchor) this.vimVisual.start(visualMode, anchor);
this.deps.requestRender();
};
this.enterVimInsert(false, false);
let consumed: boolean;
try { consumed = this.onExtensionShortcut(data); }
catch (error) {
if (this.getText() === draft) {
try { this.vimAdapter().endInsertSession(); } catch { /* Preserve the callback failure. */ }
restoreMode();
} else {
this.vimOperator.cancel();
this.vimEngine.reset();
}
throw error;
}
if (consumed || this.getText() !== draft) {
this.vimOperator.cancel();
this.vimEngine.reset();
return;
}
// A non-owning shortcut probe temporarily entered INSERT; close
// its session before restoring NORMAL and dispatching this key.
try { this.vimAdapter().endInsertSession(); } catch { return; }
restoreMode();
}
if (this.keybindingsManager.matches(data, "app.clipboard.pasteImage")) {
this.enterVimInsert();
this.onPasteImage?.();
return;
}
if (this.keybindingsManager.matches(data, "tui.editor.historyPrevious") ||
this.keybindingsManager.matches(data, "tui.editor.historyNext")) {
this.enterVimInsert();
super.handleInput(data);
return;
}
for (const [action, handler] of this.actionHandlers) {
if (action !== "app.interrupt" && this.keybindingsManager.matches(data, action)) {
if (action === "app.exit" && this.getText().length !== 0) continue;
this.enterVimInsert();
handler();
return;
}
}
}
const parsed = parseKey(data);
const singleGrapheme = (value: string): boolean =>
[...new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)].length === 1;
const printable = decodeKittyPrintable(data) ??
(parsed && singleGrapheme(parsed) ? parsed : undefined) ??
(singleGrapheme(data) ? data : undefined);
// Only unmodified presses become modal commands; other shortcuts
// remain available to Pi's application keybindings.
const command = printable && (matchesKey(data, printable as Parameters<typeof matchesKey>[1]) || data === printable)
? printable : matchesKey(data, "space") ? "Space" : undefined;
// A stale Pi menu must never take a VISUAL motion, count or
// operator. Dismiss it only after app/extension shortcut priority;
// outside VISUAL Pi retains its ordinary completion ownership.
if (this.vimNormal && this.vimVisual.active && this.isShowingAutocomplete()) {
try { this.vimAdapter().dismissAutocomplete(); } catch { return; }
}
if (this.vimNormal && !this.vimVisual.active && command === "/") {
// Pi 0.85.1 opens slash completion only at the start of the
// first line. Let its editor insert the slash and decide whether
// to open the menu; never relocate or replace an existing draft.
this.enterVimInsert();
super.handleInput(data);
return;
}
if (this.vimNormal && command) {
if (this.visualObjectPending && this.vimVisual.active) {
const around = this.visualObjectPending;
this.visualObjectPending = undefined;
try {
const adapter = this.vimAdapter();
const stops = adapter.motionBoundaries();
const range = this.vimVisual.object(this.getText(), this.getCursor(), stops, around, command);
if (range) {
adapter.readRange(range.start, range.end);
const endStops = stops[range.end.line]!;
const endIndex = endStops.indexOf(range.end.col);
const last = endIndex > 0 ? { line: range.end.line, col: endStops[endIndex - 1]! } :
range.end.line > 0 ? { line: range.end.line - 1, col: stops[range.end.line - 1]!.at(-1)! } : undefined;
if (last) {
this.visualAnchor = range.start;
this.vimVisual.start("char", range.start);
adapter.move(last);
}
}
} catch { /* Registered paste markers and unknown layouts leave the visual range intact. */ }
this.deps.requestRender(); return;
}
if (this.visualReplacePending && this.vimVisual.active) {
this.visualReplacePending = false;
if (singleGrapheme(command) && !command.includes("\n")) {
try {
const adapter = this.vimAdapter();
const range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries());
if (range) {
adapter.readRange(range.start, range.end);
const edit = this.vimOperator.visualEdit("r" + command, this.getText(), range).edit;
if (edit) { adapter.replace(edit.start, edit.end, edit.text); adapter.move(edit.cursor); }
this.vimVisual.cancel(); this.visualAnchor = undefined;
this.vimOperator.cancel(); this.vimEngine.reset();
}
} catch { /* Preserve selection when a paste marker or layout is unsafe. */ }
}
this.deps.requestRender(); return;
}
if (command === "v" || command === "V") {
if (this.vimVisual.active && this.vimVisual.kind === (command === "v" ? "char" : "line")) {
this.vimVisual.cancel(); this.visualAnchor = undefined;
} else {
let anchor = this.visualAnchor ?? this.getCursor();
if (command === "v" && !this.vimVisual.active) {
try {
const adapter = this.vimAdapter();
const stops = adapter.motionBoundaries()[anchor.line];
if (!stops) return;
if (stops.length > 1 && anchor.col === stops[stops.length - 1]) {
anchor = { line: anchor.line, col: stops[stops.length - 2]! };
adapter.move(anchor);
}
} catch { return; }
}
this.visualAnchor = anchor;
this.vimVisual.start(command === "v" ? "char" : "line", anchor);
}
this.vimOperator.cancel();
this.vimEngine.reset();
this.deps.requestRender();
return;
}
let adapter;
try { adapter = this.vimAdapter(); } catch { return; }
if (this.vimVisual.active) {
if (command === "i" || command === "a") { this.visualObjectPending = command; return; }
if (command === "r") { this.visualReplacePending = true; return; }
if (command === "o") {
const target = this.vimVisual.swap(this.getCursor());
if (target) { this.visualAnchor = this.getCursor(); adapter.move(target); }
this.deps.requestRender(); return;
}
if (["d", "x", "c", "s", "y", "p", "~", "u", "U", ">", "<", "J"].includes(command)) {
let range;
try { range = this.vimVisual.range(this.getText(), this.getCursor(), adapter.motionBoundaries()); }
catch (error) { this.notifyUnsafeVisual("range", error); return; }
if (!range) { this.notifyUnsafeVisual("range"); return; }
let stage: "readRange" | "operation" | "beginInsertSession" | "replace" | "move" = "readRange";
let beganInsert = false;
try {
const selected = adapter.readRange(range.start, range.end);
if (!selected && (!range.linewise || !this.getText())) {
this.vimVisual.cancel(); this.visualAnchor = undefined;
this.vimEngine.reset(); this.deps.requestRender();
return;
}
stage = "operation";
const operation = this.vimOperator.visualEdit(command === "x" ? "d" : command === "s" ? "c" : command, this.getText(), range);
if (command === "p" && !operation.edit) {
this.deps.notifyCompatibility?.("Vim register is empty; yank text before visual paste.");
return;
}
if (operation.edit) {
this.vimOperator.forgetRepeat();
if (operation.edit.insert) { stage = "beginInsertSession"; adapter.beginInsertSession(); beganInsert = true; }
if (operation.edit.start.line !== operation.edit.end.line || operation.edit.start.col !== operation.edit.end.col || operation.edit.text) {
stage = "replace"; adapter.replace(operation.edit.start, operation.edit.end, operation.edit.text);
}
stage = "move"; adapter.move(operation.edit.cursor);
}
if (["d", "x", "c", "s", "y", "p"].includes(command)) this.vimOperator.visualRegister(range.linewise ? selected + "\n" : selected, range.linewise);
this.vimVisual.cancel(); this.visualAnchor = undefined;
this.vimOperator.cancel(); this.vimEngine.reset();
if (operation.edit?.insert) this.enterVimInsert(true, true, false);
} catch (error) {
if (beganInsert) { try { adapter.endInsertSession(); } catch { /* Retain the original failure. */ } }
this.notifyUnsafeVisual(stage, error);
}
this.deps.requestRender(); return;
}
}
if (command === "." && !this.vimVisual.active && !this.vimOperator.isPending) {
const count = this.vimOperator.takeRepeatCount();
this.vimEngine.reset();
for (let i = 0; i < count; i++) {
const literal = this.vimOperator.repeatInsertCommand;
const inserted = this.vimOperator.repeatInsertion;
const destination = literal ? new VimNormalEngine().input(literal, this.getText(), this.getCursor(), adapter.motionBoundaries()) : undefined;
const change = literal ? undefined : this.vimOperator.repeatAt(this.getText(), this.getCursor(), adapter.motionBoundaries());
if (!change?.edit && !(destination?.handled && inserted)) break;
try {
if (inserted && /[\r\t]/.test(inserted)) break;
if (change?.edit?.insert) {
adapter.readRange(change.edit.start, change.edit.end);
adapter.beginInsertSession();
}
if (change?.edit) {
if (change.edit.text || change.edit.start.line !== change.edit.end.line || change.edit.start.col !== change.edit.end.col)
adapter.replace(change.edit.start, change.edit.end, change.edit.text);
adapter.move(change.edit.cursor);
}
if (inserted && (change?.edit?.insert || destination)) {
const at = destination?.cursor ?? change!.edit!.cursor;
adapter.replace(at, at, inserted);
}
if (change?.edit?.insert) adapter.endInsertSession();
else if (change?.edit) adapter.move(change.edit.cursor);
} catch { if (change?.edit?.insert) adapter.endInsertSession(); break; /* Unsafe paste marker: stop replay. */ }
}
this.deps.requestRender(); return;
}
const wasPending = this.vimOperator.isPending;
const operator = this.vimVisual.active ? null : this.vimOperator.input(command, this.getText(), this.getCursor(), adapter.motionBoundaries());
if (operator && (wasPending || !/^[0-9]$/.test(command))) {
if (!wasPending) this.vimEngine.reset();
if (operator.edit) {
try {
if (operator.edit.insert) adapter.beginInsertSession();
if (operator.edit.text || operator.edit.start.line !== operator.edit.end.line ||
operator.edit.start.col !== operator.edit.end.col) {
adapter.replace(operator.edit.start, operator.edit.end, operator.edit.text);
}
adapter.move(operator.edit.cursor);
if (operator.edit.insert) {
adapter.markInsertAnchor();
this.vimInsertIntent = { operator };
} else this.vimOperator.remember(operator);
if (operator.edit.insert) this.enterVimInsert(true, true, false);
} catch { if (operator.edit.insert) adapter.endInsertSession(); this.vimOperator.cancel(); }
}
this.deps.requestRender();
return;
}
if (command === "u" && !this.vimVisual.active) { adapter.undo(); this.vimEngine.reset(); this.deps.requestRender(); return; }
const motion = this.vimEngine.input(command, this.getText(), this.getCursor(), adapter.motionBoundaries());
if (motion.handled) {
if (this.vimVisual.active && motion.insert !== undefined) return;
try {
adapter.move(motion.cursor);
if (motion.insert !== undefined) {
this.enterVimInsert();
this.vimInsertIntent = { command };
}
if (motion.insert === "\n") {
this.insertTextAtCursor("\n");
if (command === "O") this.vimAdapter().move({ line: motion.cursor.line, col: 0 });
}
this.deps.requestRender();
} catch { this.vimEngine.reset(); /* Unsupported private layout: no modal mutation. */ }
return;
}
}
// Encoded Return, Tab, IME and paste are editor input, not
// app actions. Never forward an unowned NORMAL byte to insertion.
if (this.vimNormal && !this.keybindingsManager.matches(data, "app.interrupt") && !matchesKey(data, "escape")) return;
}
if (
this.promptState === PROMPT_STATE.WORKING &&
!this.isShowingAutocomplete() &&
this.keybindingsManager.matches(data, "app.interrupt")
) {
if (!this.deps.doubleEscCancelEnabled()) {
this.abortAndDispatchQueued(data);
return;
}
if (this.isPendingEscapeCancel()) {
this.pendingEscapeCancelDeadline = undefined;
this.abortAndDispatchQueued(data);
return;
}
// Pi's own idle double-Esc (empty editor -> /tree or /fork) uses a
// 500ms window; canceling a running turn is a heavier, harder-to-undo
// action, so this confirmation deliberately gets double that time.
this.pendingEscapeCancelDeadline = this.deps.now() + DOUBLE_ESC_CANCEL_WINDOW_MS;
this.deps.requestRender();
return;
}
// Idle with a non-empty draft: Pi's own idle double-Esc only acts on an
// empty editor (tree/fork), so a draft's first Esc would otherwise do
// nothing. Mirror the same swallow-then-confirm shape as the
// working-cancel gate above, on the same 500ms window as Pi's own idle
// double-Esc (issue #1218). Bash-mode drafts ("!...", the same rule
// Pi's own interactive-mode uses to detect bash mode) are Pi's own
// bash-mode Esc territory and must never reach this gate.
if (
this.promptState === PROMPT_STATE.IDLE &&
!this.isShowingAutocomplete() &&
this.keybindingsManager.matches(data, "app.interrupt")
) {
const text = this.getText();
if (text.trim() !== "" && !text.trimStart().startsWith("!")) {
// The second Esc only clears when the text is still exactly what
// it was at the first Esc; an edit in between starts a fresh
// first press on the new text instead of silently discarding it.
if (this.isPendingIdleClear() && this.pendingIdleClearText === text) {
this.pendingIdleClearDeadline = undefined;
this.pendingIdleClearText = undefined;
this.addToHistory(text);
this.setText("");
this.deps.requestRender();
return;
}
this.pendingIdleClearDeadline = this.deps.now() + IDLE_ESC_CLEAR_WINDOW_MS;
this.pendingIdleClearText = text;
this.deps.requestRender();
return;
}
}
super.handleInput(data);
}
/** Buffer a whole paste before any bytes reach Vim's command router. Pi's
* editor receives the complete frame in INSERT so it retains native paste
* normalization, marker registration and undo behavior. In NORMAL, discard
* the entire frame rather than interpreting its contents as keystrokes.
*/
private handleVimPasteFrame(data: string): boolean {
if (this.pasteFrame === undefined) {
const start = data.indexOf(BRACKETED_PASTE_START);
if (start < 0) return false;
// A frame is an atomic event: bytes before its marker must not be
// accidentally interpreted as a Vim command either.
this.pasteFrame = "";
this.pasteOverflow = false;
data = data.slice(start + BRACKETED_PASTE_START.length);
}
// Retain only the terminator's possible prefix between events. This
// avoids rescanning the entire paste for each single-character chunk.
const chunk = this.pasteTail + data;
const end = chunk.indexOf(BRACKETED_PASTE_END);
const content = end < 0 ? chunk.slice(0, -(BRACKETED_PASTE_END.length - 1)) : chunk.slice(0, end);
if (!this.pasteOverflow) {
if (this.pasteFrame.length + content.length > MAX_BRACKETED_PASTE_CHARS) {
this.pasteFrame = "";
this.pasteOverflow = true;
} else this.pasteFrame += content;
}
if (end < 0) {