/**
* proper-base — baseline editor, fullscreen, history, and footer behavior.
*
* pi's Up/Down history covers the current session only, so a new session in a
* project you have worked in for weeks starts empty. This extension seeds the
* editor from the other sessions recorded for the same working directory, and
* records every prompt as you submit it.
*
* The editor submit path is the only trusted source: Pi session messages store
* expanded skills and prompt templates rather than the exact outgoing input.
*/
import {
CustomEditor,
type ExtensionAPI,
type ExtensionContext,
getAgentDir,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import { matchesKey } from "@earendil-works/pi-tui";
import {
installAutocompleteDetails,
installInlineSlashAutocomplete,
installModelAutocompleteSubmit,
modelThinkingCommand,
sortModelAutocompleteDescending,
} from "./src/autocomplete-details.ts";
import { installClipboardLeakGuard } from "./src/clipboard-guard.ts";
import { commitGuardReason } from "./src/commit-guard.ts";
import { installEditorMouseGuard } from "./src/editor-mouse.ts";
import {
installEditorNavigation,
installPromptClear,
installReverseHistorySearch,
type ReverseHistorySearchController,
} from "./src/editor-navigation.ts";
import {
FastOverlay,
fastToggleNotice,
isFastToggle,
} from "./src/fast-mode.ts";
import { installFooterColors } from "./src/footer-colors.ts";
import {
isRecallable,
mergePrompts,
resolveBase,
WRAPPED,
} from "./src/history.ts";
import { type HistoryGuard, installHistoryGuard } from "./src/history-guard.ts";
import { omitPriorTurnImages } from "./src/image-context.ts";
import {
enableScribeCapabilities,
type ImagePreviewController,
installImagePreview,
} from "./src/image-preview.ts";
import { installJumpToBottom } from "./src/jump-to-bottom.ts";
import { installOsc8LinkIds } from "./src/osc8-link-ids.ts";
import { installOverlayScroll } from "./src/overlay-scroll.ts";
import {
applyProactiveDelegation,
readProactiveDelegationEnabled,
} from "./src/proactive-delegation.ts";
import {
createPromptDisplay,
PROMPT_DISPLAY_ENTRY,
} from "./src/prompt-display.ts";
import { installPromptJump } from "./src/prompt-jump.ts";
import { installRecorder } from "./src/recorder.ts";
import { installSelectionDismiss } from "./src/selection-dismiss.ts";
import { installFastSessionList } from "./src/session-list.ts";
import { installSettings, type SettingsController } from "./src/settings.ts";
import { pinSkillContext } from "./src/skill-context.ts";
import { installSmartSelection } from "./src/smart-selection.ts";
import {
persistDefaultModel,
persistDefaultThinkingLevel,
} from "./src/startup-defaults.ts";
import {
appendPrompt,
compactIfNeeded,
readPrompts,
storePath,
} from "./src/store.ts";
import {
installTranscriptCleanup,
type TranscriptCleanupController,
} from "./src/transcript-cleanup.ts";
import { normalizeCpaTransientError } from "./src/transient-retry.ts";
import { installWheelScrollLines } from "./src/wheel-scroll.ts";
import { installWidgetTranscript } from "./src/widget-transcript.ts";
/** Prompts seeded into the editor. Older prompts past this point are dropped. */
const MAX_ENTRIES = 200;
const CANCEL_PROMPT_COMMAND = "__proper-cancel-prompt";
const CANCEL_ANCHOR = "proper-cancel-anchor";
const RESTORE_MODEL_COMMAND = "__proper-restore-model";
const SESSION_TITLE_INSTRUCTION = `At the end of your first assistant response, add exactly one line in this format: concise 3-7 word task title. Use plain text without quotes or terminal control characters. This is hidden session metadata; do not mention it.`;
const SESSION_TITLE_PATTERN = /\s*([^<]*)<\/session_title>\s*$/i;
const SESSION_TITLE_DISPLAY_PATTERN = /\s*[\s\S]*$/i;
const SESSION_TITLE_MAX_LENGTH = 64;
type EditorFactory = NonNullable<
ReturnType
>;
type TaggedFactory = EditorFactory & { [WRAPPED]?: EditorFactory | null };
type EditorKeybindings = Parameters[2];
const FULLSCREEN_KEYBINDINGS = Symbol.for(
"pi-proper-base.fullscreen-keybindings",
);
const LEGACY_FULLSCREEN_KEYBINDINGS = Symbol.for(
"pi-proper-customs.fullscreen-keybindings",
);
const FULLSCREEN_KEYS = {
"tui.altScreen.pageUp": "ctrl+shift+pageUp",
"tui.altScreen.pageDown": "ctrl+shift+pageDown",
"tui.altScreen.top": "ctrl+shift+home",
"tui.altScreen.bottom": "ctrl+shift+end",
} as const;
type KeybindingController = { apply(): void };
type PatchedKeybindings = EditorKeybindings & {
[FULLSCREEN_KEYBINDINGS]?: true | KeybindingController;
[LEGACY_FULLSCREEN_KEYBINDINGS]?: true | KeybindingController;
};
function installKeybindings(keybindings: EditorKeybindings): void {
const patched = keybindings as PatchedKeybindings;
const apply = () => {
const imagePasteKeys = [
...new Set([
...keybindings.getKeys("app.clipboard.pasteImage"),
"ctrl+v" as const,
"ctrl+shift+v" as const,
]),
];
// Shift+Enter is already Pi's `tui.input.newLine` default; only Alt+Enter
// needs adding, and only because it must leave `app.message.followUp`.
const newLineKeys = [
...new Set([
...keybindings.getKeys("tui.input.newLine"),
"alt+enter" as const,
]),
];
const followUpKeys = keybindings
.getKeys("app.message.followUp")
.filter((key) => key !== "alt+enter");
keybindings.setUserBindings({
...keybindings.getUserBindings(),
...FULLSCREEN_KEYS,
"app.clipboard.pasteImage": imagePasteKeys,
"tui.input.newLine": newLineKeys,
"app.message.followUp": followUpKeys,
});
};
const existing =
patched[FULLSCREEN_KEYBINDINGS] ?? patched[LEGACY_FULLSCREEN_KEYBINDINGS];
if (existing && existing !== true) {
existing.apply = apply;
patched[FULLSCREEN_KEYBINDINGS] = existing;
delete patched[LEGACY_FULLSCREEN_KEYBINDINGS];
existing.apply();
return;
}
const reload = keybindings.reload.bind(keybindings);
const controller: KeybindingController = { apply };
keybindings.reload = () => {
reload();
controller.apply();
};
patched[FULLSCREEN_KEYBINDINGS] = controller;
delete patched[LEGACY_FULLSCREEN_KEYBINDINGS];
controller.apply();
}
type EditorTui = Parameters[0] & {
scrollToBottom?(): void;
};
type PromptEditor = ReturnType & {
isShowingAutocomplete?(): boolean;
};
type PendingPrompt = {
text: string;
messageTimestamp?: number;
processed: boolean;
cancelled: boolean;
entryId?: string;
originId?: string | undefined;
};
type RestoreRequest = { entryId: string; originId?: string | undefined };
/**
* Extensions may append transcript entries between submission and the prompt's
* own session entry; proper-pacify writes one before it rewrites the text. Those
* entries are the prompt's parents, so navigating to the prompt keeps them and
* leaves orphan rows behind. Aim at the leaf the submission started from, unless
* it is a prompt entry of its own (navigating there would drop that turn too) or
* no longer an ancestor because the tree moved meanwhile.
*/
function cancelTargetId(
ctx: ExtensionContext,
request: RestoreRequest,
): string {
if (!request.originId) return request.entryId;
const origin = ctx.sessionManager.getEntry(request.originId);
if (!origin || origin.type === "custom_message") return request.entryId;
if (origin.type === "message" && origin.message.role === "user") {
return request.entryId;
}
const ancestor = ctx.sessionManager
.getBranch(request.entryId)
.some((entry) => entry.id === request.originId);
return ancestor ? request.originId : request.entryId;
}
/**
* `details` shape of the `ask_user_question` tool result, from
* `@juicesharp/rpiv-ask-user-question`.
*/
type QuestionnaireDetails = { cancelled?: boolean; error?: string };
function extractSessionTitle(text: string): string | undefined {
const match = SESSION_TITLE_PATTERN.exec(text);
if (!match) return undefined;
return (
(match[1] ?? "")
.replace(/\p{Cc}/gu, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, SESSION_TITLE_MAX_LENGTH)
.trim() || undefined
);
}
type ModelReference = { provider: string; id: string };
function encodeModelReference(model: ModelReference): string {
return encodeURIComponent(
JSON.stringify({ provider: model.provider, id: model.id }),
);
}
function decodeModelReference(value: string): ModelReference | undefined {
try {
const parsed = JSON.parse(
decodeURIComponent(value.trim()),
) as Partial | null;
if (
!parsed ||
typeof parsed.provider !== "string" ||
!parsed.provider ||
typeof parsed.id !== "string" ||
!parsed.id
)
return undefined;
return { provider: parsed.provider, id: parsed.id };
} catch {
return undefined;
}
}
export default function (pi: ExtensionAPI) {
// @lat: [[lat.md/proper-base/proper-base#proper-base#Clipboard leak guard]]
installClipboardLeakGuard();
enableScribeCapabilities();
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Session listing]]
installFastSessionList(SessionManager, getAgentDir());
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Fast tier scopes]]
const fastOverlay = new FastOverlay(getAgentDir());
pi.on("before_provider_request", (event, ctx) =>
fastOverlay.rewritePayload(event.payload, ctx.model ?? undefined),
);
pi.registerCommand?.("fast-global", {
description: "Toggle CLIProxyAPI Fast mode for all sessions.",
handler: async (args, ctx) => {
if (args.trim()) {
ctx.ui.notify("Usage: /fast-global", "error");
return;
}
let enabled: boolean;
try {
enabled = fastOverlay.toggleGlobal();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
ctx.ui.notify(`Failed to save Fast mode: ${message}`, "error");
return;
}
const notice = fastToggleNotice({
scope: "global",
enabled,
otherEnabled: fastOverlay.isSessionEnabled(),
modelSupported: fastOverlay.supportsModel(ctx.model ?? undefined),
});
ctx.ui.notify(notice.message, notice.level);
},
});
let removeFooterColors: (() => void) | undefined;
let removeJumpToBottom: (() => void) | undefined;
let removePromptJump: (() => void) | undefined;
let settings: SettingsController | undefined;
let removeEditorMouseGuard: (() => void) | undefined;
let removePromptClear: (() => void) | undefined;
let removeSmartSelection: (() => void) | undefined;
let removeSelectionDismiss: (() => void) | undefined;
let removeOverlayScroll: (() => void) | undefined;
let removeOsc8LinkIds: (() => void) | undefined;
let imagePreview: ImagePreviewController | undefined;
let removeTerminalInput: (() => void) | undefined;
let transcriptCleanup: TranscriptCleanupController | undefined;
let removeWidgetTranscript: (() => void) | undefined;
let activeEditor: PromptEditor | undefined;
let activeTui: EditorTui | undefined;
let submittedPrompt: string | undefined;
let submittedOriginId: string | undefined;
let pendingPrompt: PendingPrompt | undefined;
let restoreRequest: RestoreRequest | undefined;
let sessionTitlePending = false;
const promptDisplay = createPromptDisplay();
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Prompt display]]
pi.registerMarkdownTransformer?.((markdown, context) => {
if (context.messageType === "user")
return promptDisplay.transform(markdown);
if (context.messageType !== "assistant") return markdown;
return markdown.replace(SESSION_TITLE_DISPLAY_PATTERN, "");
});
const findPendingEntry = (ctx: ExtensionContext) => {
if (!pendingPrompt?.messageTimestamp) return undefined;
return [...ctx.sessionManager.getBranch()]
.reverse()
.find(
(entry) =>
entry.type === "message" &&
entry.message.role === "user" &&
entry.message.timestamp === pendingPrompt?.messageTimestamp,
);
};
/**
* Run a `/model ` submission, if that is the shape.
*
* Pi reads a `/model` argument as one model search term, so a thinking level
* appended to it only widens that search and opens the picker. The command is
* taken over at the editor instead, and the level is applied after the model:
* every `setModel` recomputes the level from settings, so setting it first
* would be discarded. A reference matching no model is left to Pi, whose
* picker still opens on the raw text.
*/
const applyModelThinking = (ctx: ExtensionContext, text: string): boolean => {
const request = modelThinkingCommand(text);
if (!request) return false;
const available =
ctx.scopedModels.length > 0
? ctx.scopedModels.map((scoped) => scoped.model)
: ctx.modelRegistry.getAvailable();
const model = available.find(
(candidate) =>
`${candidate.provider}/${candidate.id}` === request.reference ||
candidate.id === request.reference,
);
if (!model) return false;
void (async () => {
if (!(await pi.setModel(model))) {
ctx.ui.notify(
`Could not switch to ${model.provider}/${model.id}`,
"error",
);
return;
}
pi.setThinkingLevel(request.level);
ctx.ui.notify(`Model: ${model.id} (thinking: ${request.level})`, "info");
})();
return true;
};
pi.registerCommand?.(RESTORE_MODEL_COMMAND, {
description: "Internal: restore the model after /clear",
handler: async (args, ctx) => {
const reference = decodeModelReference(args);
if (!reference) {
ctx.ui.notify("Could not restore model after /clear", "error");
return;
}
const model = ctx.modelRegistry.find(reference.provider, reference.id);
if (!model || !(await pi.setModel(model))) {
ctx.ui.notify(
`Could not restore model ${reference.provider}/${reference.id}`,
"error",
);
}
},
});
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Model-preserving clear]]
pi.registerCommand?.("clear", {
description: "Start a new session with the current model",
handler: async (_args, ctx) => {
const restoreCommand = ctx.model
? `/${RESTORE_MODEL_COMMAND} ${encodeModelReference(ctx.model)}`
: undefined;
await ctx.newSession(
restoreCommand
? {
withSession: async (replacementCtx) => {
await replacementCtx.sendUserMessage(restoreCommand, {
expandPromptTemplates: true,
});
},
}
: undefined,
);
},
});
pi.registerCommand?.(CANCEL_PROMPT_COMMAND, {
description: "Internal: remove an unprocessed cancelled prompt",
handler: async (_args, ctx) => {
const request = restoreRequest;
restoreRequest = undefined;
if (!request) return;
const target = ctx.sessionManager.getEntry(request.entryId);
if (target?.type !== "message" || target.message.role !== "user") return;
const targetId = cancelTargetId(ctx, request);
try {
if (ctx.sessionManager.getLeafId() === targetId) {
pi.appendEntry(CANCEL_ANCHOR, { targetId });
}
await ctx.navigateTree(targetId, { summarize: false });
} catch (error) {
ctx.ui.notify(
`Could not remove cancelled prompt: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
}
},
});
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Sticky startup defaults]]
pi.on("model_select", (event) => {
if (event.source === "restore") return;
persistDefaultModel(getAgentDir(), event.model);
});
pi.on("thinking_level_select", (event, ctx) => {
persistDefaultThinkingLevel(
getAgentDir(),
event.level,
ctx.model ?? undefined,
);
});
pi.on("input", (event) => {
if (event.source === "interactive") {
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Pinned transcript scrolling]]
if (event.text.trim()) activeTui?.scrollToBottom?.();
promptDisplay.captureInput(event.text, pi.getCommands());
}
if (
event.source === "interactive" &&
event.streamingBehavior !== undefined
) {
submittedPrompt = undefined;
return;
}
if (event.source !== "interactive" || !event.text.trim()) return;
pendingPrompt = {
text: submittedPrompt ?? event.text,
originId: submittedPrompt ? submittedOriginId : undefined,
processed: false,
cancelled: false,
};
submittedPrompt = undefined;
});
pi.on("message_start", (event) => {
if (event.message.role === "user") {
promptDisplay.captureUser(event.message);
if (pendingPrompt) {
pendingPrompt.messageTimestamp = event.message.timestamp;
}
return;
}
if (!pendingPrompt) return;
if (
event.message.role === "assistant" &&
event.message.stopReason !== "aborted"
) {
// Streaming opens the assistant message with a "pending" partial as
// soon as response headers arrive — instantly behind a local proxy,
// with zero tokens produced. That is not assistant processing yet;
// content arrival marks it through message_update instead.
if (event.message.stopReason !== "pending") {
pendingPrompt.processed = true;
}
imagePreview?.clear();
}
});
pi.on("message_update", () => {
if (pendingPrompt) pendingPrompt.processed = true;
});
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Model image context]]
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Skill context]]
pi.on("context", (event, ctx) => {
const messages = pinSkillContext(
omitPriorTurnImages(event.messages),
ctx.sessionManager.getBranch(),
);
if (messages !== event.messages) return { messages };
});
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Proactive delegation]]
pi.on("before_agent_start", (event, ctx) => {
if (!readProactiveDelegationEnabled(getAgentDir())) return;
const tools =
event.systemPromptOptions?.selectedTools ?? pi.getActiveTools?.() ?? [];
const systemPrompt = applyProactiveDelegation(
event.systemPrompt,
tools.includes("subagent"),
ctx.scopedModels.map((scoped) => scoped.model),
);
if (systemPrompt !== undefined) return { systemPrompt };
});
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Automatic session title]]
pi.on("before_agent_start", (event) => {
if (!sessionTitlePending || pi.getSessionName?.()) {
sessionTitlePending = false;
return;
}
return {
systemPrompt: `${event.systemPrompt}\n\n${SESSION_TITLE_INSTRUCTION}`,
};
});
pi.on("message_end", (event) => {
if (event.message.role === "assistant") {
transcriptCleanup?.completeAssistant(event.message);
}
const normalized = normalizeCpaTransientError(event.message);
if (normalized !== event.message) return { message: normalized };
});
pi.on("message_end", (event) => {
if (
!sessionTitlePending ||
event.message.role !== "assistant" ||
event.message.stopReason === "aborted" ||
event.message.stopReason === "error" ||
pi.getSessionName?.()
) {
return;
}
const title = event.message.content.find(
(part) => part.type === "text" && extractSessionTitle(part.text),
);
if (title?.type !== "text") {
if (
event.message.stopReason === "stop" ||
event.message.stopReason === "length"
) {
sessionTitlePending = false;
}
return;
}
const extracted = extractSessionTitle(title.text);
if (!extracted) return;
sessionTitlePending = false;
pi.setSessionName?.(extracted);
});
pi.on("agent_start", () => {
transcriptCleanup?.start();
});
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Commit message guard]]
pi.on("tool_call", (event) => {
if (event.toolName !== "bash" && event.toolName !== "quill_execute") return;
const command = (event.input as { command?: unknown } | undefined)?.command;
if (typeof command !== "string") return;
const reason = commitGuardReason(command);
if (reason) return { block: true, reason };
});
pi.on("tool_execution_start", () => {
if (pendingPrompt) pendingPrompt.processed = true;
});
pi.on("tool_execution_end", (event) => {
transcriptCleanup?.completeTool(event.toolCallId);
});
pi.on("agent_settled", (_event, ctx) => {
const prompts = promptDisplay.drain();
if (prompts.length) pi.appendEntry(PROMPT_DISPLAY_ENTRY, { prompts });
const ui = ctx.ui as ExtensionContext["ui"] & {
getToolsExpanded?(): boolean;
setToolsExpanded?(expanded: boolean): void;
};
if (ui.getToolsExpanded?.()) ui.setToolsExpanded?.(false);
transcriptCleanup?.settle();
submittedPrompt = undefined;
const candidate = pendingPrompt;
if (!candidate) return;
if (candidate.cancelled && !candidate.processed) {
const entry = candidate.entryId
? ctx.sessionManager.getEntry(candidate.entryId)
: findPendingEntry(ctx);
if (entry?.type === "message" && entry.message.role === "user") {
restoreRequest = { entryId: entry.id, originId: candidate.originId };
pendingPrompt = undefined;
pi.sendUserMessage(`/${CANCEL_PROMPT_COMMAND}`, {
expandPromptTemplates: true,
});
return;
}
}
pendingPrompt = undefined;
imagePreview?.clear();
});
// Esc on the questionnaire resolves a normal tool result, so the turn
// continues and the model answers a decline it did not need. Aborting
// returns to the prompt instead, matching Esc during streaming.
pi.on("tool_result", (event, ctx) => {
if (event.toolName !== "ask_user_question") return;
const details = event.details as QuestionnaireDetails | undefined;
// The tool also reports `cancelled` for host and validation failures,
// which carry `error`. Those must reach the model so it can fall back to
// asking in plain text.
if (details?.cancelled && !details.error) ctx.abort();
});
pi.on("session_shutdown", () => {
removeFooterColors?.();
removeFooterColors = undefined;
removeJumpToBottom?.();
removeJumpToBottom = undefined;
removePromptJump?.();
removePromptJump = undefined;
settings?.dispose();
settings = undefined;
removeEditorMouseGuard?.();
removeEditorMouseGuard = undefined;
removePromptClear?.();
removePromptClear = undefined;
removeSmartSelection?.();
removeSmartSelection = undefined;
removeSelectionDismiss?.();
removeSelectionDismiss = undefined;
removeOverlayScroll?.();
removeOverlayScroll = undefined;
removeOsc8LinkIds?.();
removeOsc8LinkIds = undefined;
imagePreview?.dispose();
imagePreview = undefined;
removeTerminalInput?.();
removeTerminalInput = undefined;
removeWidgetTranscript?.();
removeWidgetTranscript = undefined;
transcriptCleanup?.uninstall();
transcriptCleanup = undefined;
activeEditor = undefined;
activeTui = undefined;
submittedPrompt = undefined;
pendingPrompt = undefined;
restoreRequest = undefined;
sessionTitlePending = false;
promptDisplay.clear();
});
pi.on("session_start", async (_event, ctx) => {
// Session Fast never survives into a new or restored session.
fastOverlay.resetSession();
promptDisplay.restore(ctx.sessionManager.getBranch());
sessionTitlePending =
!pi.getSessionName?.() &&
!ctx.sessionManager
.getBranch()
.some(
(entry) =>
entry.type === "message" && entry.message.role === "assistant",
);
submittedPrompt = undefined;
pendingPrompt = undefined;
restoreRequest = undefined;
removeTerminalInput?.();
removeTerminalInput = ctx.ui.onTerminalInput?.((data) => {
const candidate = pendingPrompt;
const promptText = candidate?.text ?? submittedPrompt;
if (data !== "\x1b" || !promptText || candidate?.processed === true) {
return undefined;
}
if (activeEditor?.isShowingAutocomplete?.()) return undefined;
const focused = (
activeTui as EditorTui & {
getFocusedComponent?(): unknown;
}
)?.getFocusedComponent?.();
if (focused && focused !== activeEditor) return undefined;
const entry = findPendingEntry(ctx);
if (!candidate || (!entry && ctx.isIdle())) {
ctx.ui.setEditorText(promptText);
submittedPrompt = undefined;
pendingPrompt = undefined;
return undefined;
}
candidate.cancelled = true;
if (entry) candidate.entryId = entry.id;
else delete candidate.entryId;
ctx.ui.setEditorText(promptText);
return undefined;
});
ctx.ui.addAutocompleteProvider?.((current) =>
sortModelAutocompleteDescending(current, () => pi.getThinkingLevel?.()),
);
const store = storePath(getAgentDir(), ctx.cwd);
compactIfNeeded(store);
// Stores written before command filtering still hold UI commands.
const commands = pi.getCommands();
const seeded = mergePrompts(readPrompts(store), MAX_ENTRIES).filter(
(text) => isRecallable(text, commands),
);
let historyGuard: HistoryGuard | undefined;
let historySearch: ReverseHistorySearchController | undefined;
const record = (text: string, sourceText: string) => {
if (isRecallable(text, pi.getCommands())) {
appendPrompt(store, text);
historyGuard?.add(text);
historySearch?.add(text);
}
if (
!sourceText.trimStart().startsWith("/") &&
!sourceText.trimStart().startsWith("!")
) {
submittedPrompt = sourceText;
// Captured here rather than on the `input` event: extensions that
// wrap Pi's input dispatch have already appended their own entries
// by the time that event reaches this extension.
submittedOriginId = ctx.sessionManager.getLeafId() ?? undefined;
}
};
// Wrap the installed editor rather than replacing it, so this composes
// with extensions that provide their own editor.
const base = resolveBase(ctx.ui.getEditorComponent());
const factory: TaggedFactory = (tui, theme, keybindings) => {
installKeybindings(keybindings);
const editor =
base?.(tui, theme, keybindings) ??
new CustomEditor(tui, theme, keybindings);
activeEditor = editor as PromptEditor;
activeTui = tui;
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Wheel scroll rate]]
installWheelScrollLines(tui);
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Settled transcript]]
transcriptCleanup?.uninstall();
transcriptCleanup = installTranscriptCleanup(tui, ctx);
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Transcript widgets]]
removeWidgetTranscript?.();
removeWidgetTranscript = installWidgetTranscript(tui, editor);
removeSmartSelection?.();
removeSmartSelection = installSmartSelection(tui);
removeSelectionDismiss?.();
// The copy action reads the active selection after this listener runs,
// so its keystroke must not dismiss the selection it is about to copy.
removeSelectionDismiss = installSelectionDismiss(tui, (data) =>
keybindings
.getKeys("app.message.copy")
.some((key) => matchesKey(data, key)),
);
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Overlay transcript scrolling]]
removeOverlayScroll?.();
removeOverlayScroll = installOverlayScroll(tui);
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Hyperlink identity]]
removeOsc8LinkIds?.();
removeOsc8LinkIds = installOsc8LinkIds(tui);
historyGuard = installHistoryGuard(editor);
removeJumpToBottom?.();
removeJumpToBottom = installJumpToBottom(editor, tui);
removePromptJump?.();
settings?.dispose();
settings = installSettings(tui, editor, getAgentDir());
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Prompt mouse clicks]]
removeEditorMouseGuard?.();
removeEditorMouseGuard = installEditorMouseGuard(
editor,
() => settings?.editorMouse() !== false,
);
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Session action rail]]
removePromptJump = installPromptJump(tui, {
color: (value) => ctx.ui.theme.fg("muted", value),
subtle: (value) => ctx.ui.theme.fg("dim", value),
outline: () =>
settings?.enabled() !== false
? (transcriptCleanup?.outline() ?? [])
: [],
});
imagePreview?.dispose();
imagePreview = installImagePreview(editor, tui, {
fallbackColor: (value) => ctx.ui.theme.fg("dim", value),
loadingColor: (value) => ctx.ui.theme.fg("accent", value),
});
installEditorNavigation(editor, keybindings);
installInlineSlashAutocomplete(editor);
installRecorder(
editor,
record,
(text) => imagePreview?.prepare(text) ?? text,
// pi dispatches extension commands before the `input` event, so the
// provider's global `/fast` can only be repurposed as a session
// toggle by consuming it at the editor before pi ever parses it.
(text) => {
if (isFastToggle(text)) {
const notice = fastToggleNotice({
scope: "session",
enabled: fastOverlay.toggleSession(),
otherEnabled: fastOverlay.isGlobalEnabled(),
modelSupported: fastOverlay.supportsModel(ctx.model ?? undefined),
});
ctx.ui.notify(notice.message, notice.level);
tui.requestRender();
return true;
}
// @lat: [[lat.md/proper-base/lifecycle#Prompt history lifecycle#Model thinking argument]]
return applyModelThinking(ctx, text);
},
);
installModelAutocompleteSubmit(editor, keybindings);
removePromptClear?.();
removePromptClear = installPromptClear(editor, tui, keybindings, ctx);
historySearch = installReverseHistorySearch(
editor,
tui,
keybindings,
seeded,
MAX_ENTRIES,
);
installAutocompleteDetails(editor, tui, theme);
removeFooterColors?.();
removeFooterColors = installFooterColors(tui, ctx, () =>
fastOverlay.isEffectiveFor(ctx.model ?? undefined),
);
for (const prompt of seeded) historyGuard?.add(prompt);
return editor;
};
factory[WRAPPED] = base ?? null;
ctx.ui.setEditorComponent(factory);
});
}