Merge remote-tracking branch 'upstream/main' into feat/error-notify

This commit is contained in:
Mathews-Tom
2026-07-08 20:27:10 +05:30
141 changed files with 7105 additions and 693 deletions
@@ -90,16 +90,20 @@ interface RoleAssignment {
autoSelected: boolean;
}
type ModelSelectorAction = "modelRole" | "retryFallback";
type RoleSelectCallback = (
model: Model,
role: string | null,
thinkingLevel?: ConfiguredThinkingLevel,
selector?: string,
action?: ModelSelectorAction,
) => void;
type CancelCallback = () => void;
interface MenuRoleAction {
label: string;
role: string; // now accepts custom role strings
role: string;
action: ModelSelectorAction;
}
interface ProviderTabState {
@@ -284,14 +288,19 @@ export class ModelSelectorComponent extends Container {
}
#buildMenuRoleActions(): void {
this.#menuRoleActions = getKnownRoleIds(this.#settings).map(role => {
const roleActions = getKnownRoleIds(this.#settings).map(role => {
const roleInfo = getRoleInfo(role, this.#settings);
const roleLabel = roleInfo.tag ? `${roleInfo.tag} (${roleInfo.name})` : roleInfo.name;
return {
label: `Set as ${roleLabel}`,
role,
action: "modelRole" as const,
};
});
this.#menuRoleActions = [
...roleActions,
{ label: "Set as DEFAULT retry fallback", role: "default", action: "retryFallback" },
];
}
#loadRoleModels(autoCandidateModels?: ReadonlyArray<Model>): void {
@@ -1195,6 +1204,11 @@ export class ModelSelectorComponent extends Container {
if (this.#menuStep === "role") {
const action = this.#menuRoleActions[this.#menuSelectedIndex];
if (!action) return;
if (action.action === "retryFallback") {
this.#handleSelect(selectedItem, action.role, undefined, action.action);
this.#closeMenu();
return;
}
this.#menuSelectedRole = action.role;
this.#menuStep = "thinking";
this.#menuSelectedIndex = this.#getThinkingPreselectIndex(action.role, selectedItem.model);
@@ -1206,7 +1220,7 @@ export class ModelSelectorComponent extends Container {
const thinkingOptions = this.#getThinkingLevelsForModel(selectedItem.model);
const thinkingLevel = thinkingOptions[this.#menuSelectedIndex];
if (!thinkingLevel) return;
this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel);
this.#handleSelect(selectedItem, this.#menuSelectedRole, thinkingLevel, "modelRole");
this.#closeMenu();
return;
}
@@ -1225,13 +1239,23 @@ export class ModelSelectorComponent extends Container {
}
}
#handleSelect(item: ModelItem, role: string | null, thinkingLevel?: ConfiguredThinkingLevel): void {
#handleSelect(
item: ModelItem,
role: string | null,
thinkingLevel?: ConfiguredThinkingLevel,
action: ModelSelectorAction = "modelRole",
): void {
if (this.#isItemDisabled(item)) {
return;
}
// For temporary role, don't save to settings - just notify caller
if (role === null) {
this.#onSelectCallback(item.model, null, undefined, item.selector);
this.#onSelectCallback(item.model, null, undefined, item.selector, action);
return;
}
if (action === "retryFallback") {
this.#onSelectCallback(item.model, role, undefined, item.selector, action);
return;
}
@@ -1241,7 +1265,7 @@ export class ModelSelectorComponent extends Container {
this.#roles[role] = { model: item.model, thinkingLevel: selectedThinkingLevel, autoSelected: false };
// Notify caller (for updating agent state if needed)
this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector);
this.#onSelectCallback(item.model, role, selectedThinkingLevel, item.selector, action);
// Update list to show new badges
this.#updateList();
@@ -180,7 +180,7 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
}
if (schemaType === "record") {
return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : null;
return path === "providers.maxInFlightRequests" ? { ...base, type: "providerLimits" } : { ...base, type: "text" };
}
return null;
@@ -1235,9 +1235,21 @@ export class StatusLineComponent implements Component {
}
}
}
const leftOverflowDropIndex = (): number => {
// Preserve the current working directory as long as possible. The
// previous right-to-left pop could collapse a normal-width bar to
// just the model segment, hiding the path before less-critical left
// segments such as model/mode/collab were removed.
for (let i = leftSegIds.length - 1; i >= 0; i--) {
if (leftSegIds[i] !== "path") return i;
}
return left.length - 1;
};
while (totalWidth() > topFillWidth && left.length > 0) {
left.pop();
leftSegIds.pop();
const dropIdx = leftOverflowDropIndex();
left.splice(dropIdx, 1);
leftSegIds.splice(dropIdx, 1);
leftWidth = groupWidth(left, leftCapWidth, leftSepWidth);
}
}
@@ -849,11 +849,7 @@ export class CommandController {
}
async #runNewSessionFlow(options?: NewSessionOptions, label: string = "New session started"): Promise<void> {
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.clearTransientSessionUi();
if (this.ctx.session.isCompacting) {
this.ctx.session.abortCompaction();
@@ -867,14 +863,9 @@ export class CommandController {
this.ctx.statusLine.invalidate();
this.ctx.statusLine.resetActiveTime();
this.ctx.ui.requestRender();
this.ctx.updateEditorBorderColor();
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
this.ctx.clearTransientSessionUi();
this.ctx.resetTranscript();
this.ctx.present([new Spacer(1), new Text(`${theme.fg("accent", `${theme.status.success} ${label}`)}`, 1, 1)]);
await this.ctx.reloadTodos();
@@ -914,7 +905,7 @@ export class CommandController {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
const success = await this.ctx.session.fork();
if (!success) {
@@ -1177,7 +1168,7 @@ export class CommandController {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
const label = isAuto ? "Auto-compacting context... (esc to cancel)" : "Compacting context... (esc to cancel)";
const compactingLoader = new Loader(
@@ -1207,7 +1198,7 @@ export class CommandController {
await this.ctx.session.compact(instructions, options);
compactingLoader.stop();
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
this.ctx.rebuildChatFromMessages();
this.ctx.statusLine.invalidate();
@@ -1223,7 +1214,7 @@ export class CommandController {
}
} finally {
compactingLoader.stop();
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
// Run the caller's pre-flush hook (e.g. the plan-approval model transition)
// before queued user input is dispatched, so any turn queued during
@@ -1252,7 +1243,7 @@ export class CommandController {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
const handoffLoader = new Loader(
this.ctx.ui,
@@ -1273,11 +1264,10 @@ export class CommandController {
return;
}
// Rebuild chat from the new session (which now contains the handoff document)
this.ctx.rebuildChatFromMessages();
// Rebuild chat from the new session (which now contains the handoff document).
this.ctx.clearTransientSessionUi();
this.ctx.renderInitialMessages();
this.ctx.statusLine.invalidate();
this.ctx.ui.requestRender();
this.ctx.updateEditorBorderColor();
await this.ctx.reloadTodos();
@@ -1297,9 +1287,9 @@ export class CommandController {
}
} finally {
handoffLoader.stop();
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
this.ctx.ui.requestRender();
this.ctx.ui.requestRender(true, { clearScrollback: true });
}
}
@@ -379,7 +379,7 @@ export class EventController {
if (this.ctx.retryLoader) {
this.ctx.retryLoader.stop();
this.ctx.retryLoader = undefined;
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
this.#cancelIdleCompaction();
this.#cancelIdleRecap();
@@ -1083,7 +1083,7 @@ export class EventController {
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
if (this.ctx.streamingComponent) {
this.ctx.chatContainer.removeChild(this.ctx.streamingComponent);
@@ -1126,9 +1126,9 @@ export class EventController {
/**
* Tear down the live "Working…" loader: stop its animation timer AND clear the
* reference. A transient overlay (auto-compaction / auto-retry) that only ran
* `statusContainer.clear()` detached the loader from the container but left
* `ctx.loadingAnimation` set, so the resumed turn's `agent_start` →
* reference. A transient overlay (auto-compaction / auto-retry) can remove the
* loader from the container while leaving `ctx.loadingAnimation` set, so the
* resumed turn's `agent_start` →
* `ensureLoadingAnimation()` (guarded by `if (!this.loadingAnimation)`) skipped
* re-adding it and the spinner vanished while the agent kept streaming. Nulling
* the reference here lets the next `agent_start` recreate and re-attach it.
@@ -1169,7 +1169,7 @@ export class EventController {
this.#cancelIdleRecap();
this.#setTerminalProgress(true);
this.#stopWorkingLoader();
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
const reasonText =
event.reason === "overflow"
? "Context overflow detected, "
@@ -1204,7 +1204,7 @@ export class EventController {
if (this.ctx.autoCompactionLoader) {
this.ctx.autoCompactionLoader.stop();
this.ctx.autoCompactionLoader = undefined;
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
const isHandoffAction = event.action === "handoff";
const isShakeAction = event.action === "shake";
@@ -1246,12 +1246,12 @@ export class EventController {
} else if (event.errorMessage) {
this.ctx.showWarning(event.errorMessage);
} else if (isHandoffAction) {
this.ctx.chatContainer.clear();
this.ctx.clearTransientSessionUi();
this.ctx.lastAssistantUsage = undefined;
this.ctx.rebuildChatFromMessages();
this.ctx.renderInitialMessages();
this.ctx.statusLine.invalidate();
this.ctx.ui.requestRender();
await this.ctx.reloadTodos();
this.ctx.ui.requestRender(true, { clearScrollback: true });
this.ctx.showStatus("Auto-handoff completed");
} else if (event.skipped) {
// Benign skip: no model selected, no candidate models available, or nothing
@@ -1269,7 +1269,7 @@ export class EventController {
async #handleAutoRetryStart(event: Extract<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
this.#trackRetrySupersededAssistantComponent(this.#lastAssistantComponent);
this.#stopWorkingLoader();
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
if (AIError.is(event.errorId, AIError.Flag.ThinkingLoop)) {
// The retry path drops the failed assistant from runtime context. Do not
// restore its inline Error row; just unpin the fixed-region banner so the
@@ -1293,7 +1293,7 @@ export class EventController {
if (this.ctx.retryLoader) {
this.ctx.retryLoader.stop();
this.ctx.retryLoader = undefined;
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
if (event.success) {
let appliedRecovered = false;
@@ -162,18 +162,12 @@ export class ExtensionUiController {
waitForIdle: () => this.ctx.session.agent.waitForIdle(),
reload: async () => {
await this.ctx.session.reload();
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus("Reloaded session");
},
newSession: async options => {
// Stop any loading animation
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.clearTransientSessionUi();
// Create new session
this.clearExtensionTerminalInputListeners();
@@ -192,15 +186,8 @@ export class ExtensionUiController {
// Reset and update status line
this.ctx.statusLine.invalidate();
this.ctx.statusLine.resetActiveTime();
this.ctx.ui.requestRender();
// Clear UI state
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
this.ctx.clearTransientSessionUi();
this.ctx.resetTranscript();
this.ctx.present([
new Spacer(1),
@@ -218,7 +205,6 @@ export class ExtensionUiController {
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.editor.setText(result.selectedText);
@@ -233,7 +219,6 @@ export class ExtensionUiController {
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -251,7 +236,6 @@ export class ExtensionUiController {
return { cancelled: true };
}
setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd());
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
return { cancelled: false };
@@ -398,18 +382,12 @@ export class ExtensionUiController {
waitForIdle: () => this.ctx.session.agent.waitForIdle(),
reload: async () => {
await this.ctx.session.reload();
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus("Reloaded session");
},
newSession: async options => {
// Stop any loading animation
if (this.ctx.loadingAnimation) {
this.ctx.loadingAnimation.stop();
this.ctx.loadingAnimation = undefined;
}
this.ctx.statusContainer.clear();
this.ctx.clearTransientSessionUi();
// Create new session
this.clearExtensionTerminalInputListeners();
@@ -425,12 +403,8 @@ export class ExtensionUiController {
}
// Clear UI state
this.ctx.chatContainer.clear();
this.ctx.pendingMessagesContainer.clear();
this.ctx.compactionQueuedMessages = [];
this.ctx.streamingComponent = undefined;
this.ctx.streamingMessage = undefined;
this.ctx.pendingTools.clear();
this.ctx.clearTransientSessionUi();
this.ctx.resetTranscript();
this.ctx.present([
new Spacer(1),
@@ -448,7 +422,6 @@ export class ExtensionUiController {
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.editor.setText(result.selectedText);
@@ -463,7 +436,6 @@ export class ExtensionUiController {
}
// Update UI
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -480,7 +452,6 @@ export class ExtensionUiController {
if (!result) {
return { cancelled: true };
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
return { cancelled: false };
@@ -86,6 +86,20 @@ function hasPasteText(value: unknown): value is PasteTarget {
return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
}
const SHELL_PROMPT_COMMAND_RE =
/^(?:\.{0,2}\/|~\/|cd(?:\s|$)|sudo(?:\s|$)|git(?:\s|$)|bun(?:\s|$)|npm(?:\s|$)|pnpm(?:\s|$)|yarn(?:\s|$)|node(?:\s|$)|python\d*(?:\s|$)|cargo(?:\s|$)|go(?:\s|$)|make(?:\s|$)|docker(?:\s|$)|kubectl(?:\s|$))/;
const SHELL_PROMPT_OPERATOR_RE = /(?:^|\s)(?:&&|\|\||\||2>&1|[<>]{1,2})(?:\s|$)/;
const OMP_STATUS_LINE_RE = /^\s*in:\s+\d+\s+out:\s+\d+(?:\s+cache\s+\S+)?\s+t:\s+\S+\s+tok\/s:\s+\S+/m;
function looksLikePastedShellPrompt(code: string): boolean {
const firstLine = code.split("\n", 1)[0]?.trimStart() ?? "";
return (
SHELL_PROMPT_COMMAND_RE.test(firstLine) ||
SHELL_PROMPT_OPERATOR_RE.test(firstLine) ||
OMP_STATUS_LINE_RE.test(code)
);
}
function pythonCommandPrefixLength(trimmedText: string): 0 | 1 | 2 {
if (trimmedText.charCodeAt(0) !== 36 /* $ */) return 0;
if (trimmedText.charCodeAt(1) === 123 /* { */) return 0;
@@ -100,8 +114,10 @@ function parsePythonCommandInput(text: string): { code: string; isExcluded: bool
const trimmed = text.trimStart();
const prefixLength = pythonCommandPrefixLength(trimmed);
if (prefixLength === 0) return undefined;
const code = trimmed.slice(prefixLength).trim();
if (prefixLength === 1 && looksLikePastedShellPrompt(code)) return undefined;
return {
code: trimmed.slice(prefixLength).trim(),
code,
isExcluded: prefixLength === 2,
};
}
@@ -536,7 +552,7 @@ export class InputController {
const wasPythonMode = this.ctx.isPythonMode;
const trimmed = text.trimStart();
this.ctx.isBashMode = trimmed.startsWith("!");
this.ctx.isPythonMode = pythonCommandPrefixLength(trimmed) > 0;
this.ctx.isPythonMode = parsePythonCommandInput(trimmed) !== undefined;
if (wasBashMode !== this.ctx.isBashMode || wasPythonMode !== this.ctx.isPythonMode) {
this.ctx.updateEditorBorderColor();
}
@@ -88,12 +88,14 @@ function raceAbortSignal<T>(promise: Promise<T>, signal: AbortSignal, createErro
const MCP_AUTH_MIN_WRAP_WIDTH = 16;
/**
* Wrap `url` into rows that each fit inside `width`, prefixed by a shared
* single-column indent so nested composition doesn't touch column 0. When the
* label + URL fit on one line, returns a single row; otherwise puts the label
* on its own row and slices the URL into fixed-width chunks. URL chunks are
* plain code points — browsers strip whitespace when pasted into the address
* bar, so a multi-row selection copies back to the intact URL.
* Wrap `url` into rows that each fit inside `width`. When the label + URL fit
* on one line, returns a single indented row; otherwise puts the label on its
* own indented row and slices the URL into fixed-width chunks that start at
* column 0. Continuation chunks carry ZERO leading bytes on purpose: a
* multi-row terminal selection includes the newline plus any leading indent,
* and while address bars strip newlines they preserve or percent-encode
* embedded spaces — an indent would corrupt the URL at every chunk boundary
* (silently, when the damage lands inside a query value).
*/
function wrapUrlRows(label: string, url: string, width: number): string[] {
const indent = " ";
@@ -103,10 +105,9 @@ function wrapUrlRows(label: string, url: string, width: number): string[] {
if (inlineWidth <= effective) {
return [`${indent}${theme.fg("muted", `${label} ${sanitized}`)}`];
}
const chunkWidth = Math.max(1, effective - indent.length);
const rows: string[] = [`${indent}${theme.fg("muted", label)}`];
for (let i = 0; i < sanitized.length; i += chunkWidth) {
rows.push(`${indent}${theme.fg("muted", sanitized.slice(i, i + chunkWidth))}`);
for (let i = 0; i < sanitized.length; i += effective) {
rows.push(theme.fg("muted", sanitized.slice(i, i + effective)));
}
return rows;
}
@@ -593,13 +593,27 @@ export class SelectorController {
this.ctx.settings,
this.ctx.session.modelRegistry,
this.ctx.session.scopedModels,
async (model, role, thinkingLevel, selector) => {
async (model, role, thinkingLevel, selector, action) => {
// `auto` is session-global: never baked into a per-role model value
// (it can't round-trip through `model:<level>`). Apply it to the session
// separately and persist via `defaultThinkingLevel`.
const isAuto = thinkingLevel === AUTO_THINKING;
const concreteThinking = isAuto ? undefined : thinkingLevel;
const selectorValue = selector ?? `${model.provider}/${model.id}`;
try {
if (action === "retryFallback" && role !== null) {
const fallbackSelector = formatModelSelectorValue(selectorValue, concreteThinking);
const fallbackChains = this.ctx.settings.get("retry.fallbackChains");
const chain = Array.isArray(fallbackChains[role]) ? fallbackChains[role] : [];
this.ctx.settings.set("retry.fallbackChains", {
...fallbackChains,
[role]: [fallbackSelector, ...chain.filter(existing => existing !== fallbackSelector)],
});
const roleInfo = getRoleInfo(role, settings);
const roleLabel = roleInfo?.name ?? role;
this.ctx.showStatus(`${roleLabel} fallback model: ${fallbackSelector}`);
return;
}
if (role === null) {
// Temporary: update agent state but don't persist the model to settings
await this.ctx.session.setModelTemporary(model);
@@ -771,7 +785,6 @@ export class SelectorController {
return;
}
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
this.ctx.editor.setText(result.selectedText);
done();
@@ -915,7 +928,6 @@ export class SelectorController {
// Update UI — rebuild the display transcript for the new leaf (the
// context from navigateTree is the LLM context, not the transcript).
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
if (result.editorText && !this.ctx.editor.getText().trim()) {
@@ -927,7 +939,7 @@ export class SelectorController {
} finally {
if (summaryLoader) {
summaryLoader.stop();
this.ctx.statusContainer.clear();
this.ctx.statusContainer.disposeChildren();
}
this.ctx.editor.onEscape = originalOnEscape;
}
@@ -1067,7 +1079,6 @@ export class SelectorController {
this.ctx.updateEditorBorderColor();
// Clear and re-render the chat
this.ctx.chatContainer.clear();
this.ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.ctx.reloadTodos();
this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session");
@@ -0,0 +1,75 @@
/**
* Autocomplete for GitHub issue/PR references typed as `#<number>` (e.g. `#3164`).
*
* Mirrors the `@` file-reference and `scheme://` internal-url conventions: the
* token is rewritten to an internal URL (`pr://3164` or `issue://3164`) plus a
* trailing space, and the existing tool-mediated pipeline (the `read` tool →
* InternalUrlRouter → `gh`) resolves it from the session cwd's git remote.
*
* No network at suggestion time — candidates are generated locally. GitHub
* shares the issue/PR number space and there is no cheap way to tell which a
* given number is while typing, so both a PR and an Issue candidate are offered
* by default. Naming the type first (`pr #3164` / `issue #3164`) constrains the
* candidates to that kind. Anything that is not a standalone `#<number>` token
* keeps falling through to the existing prompt-action menu.
*/
import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
/** Candidate kinds, in default display order. */
const GITHUB_REF_KINDS = [
{ qualifier: "pr", scheme: "pr", label: "PR", description: "GitHub pull request" },
{ qualifier: "issue", scheme: "issue", label: "Issue", description: "GitHub issue" },
] as const;
export interface GithubRefContext {
/** Text to replace on accept: `#3164`, or `pr #3164` when a qualifier precedes it. */
prefix: string;
/** Type the user named (`pr`/`pull` → `pr`, `issue` → `issue`), or null to offer both. */
qualifier: "pr" | "issue" | null;
/** The numeric reference, e.g. `3164`. */
number: string;
}
/**
* A standalone `#<positive-number>` token ending at the cursor. The `#` must be
* preceded by a token boundary (start, whitespace, or an opening quote/paren/`<`/`=`,
* matching the internal-URL boundary set) so embedded hashes like `owner/repo#N`,
* `foo#N`, `C#12`, or a URL fragment do not match. An optional `pr`/`pull`/`issue`
* qualifier word (case-insensitive) immediately before the `#` constrains the kind.
*/
const GITHUB_REF_TOKEN_RE = /(?:^|[\s"'`(<=])(?:(pr|pull|issue)(\s+))?#([1-9]\d*)$/i;
export function getGithubRefContext(textBeforeCursor: string): GithubRefContext | null {
const match = textBeforeCursor.match(GITHUB_REF_TOKEN_RE);
if (!match) return null;
const qualifierWord = match[1];
const whitespace = match[2] ?? "";
const number = match[3] ?? "";
return {
prefix: qualifierWord ? `${qualifierWord}${whitespace}#${number}` : `#${number}`,
qualifier: !qualifierWord ? null : qualifierWord.toLowerCase() === "issue" ? "issue" : "pr",
number,
};
}
/**
* Suggestions for a `#<number>` token. Both kinds are offered unless the user
* named a type (`pr #3164` / `issue #3164`), in which case only that kind is
* offered. Returns `null` when the text before the cursor is not a standalone
* `#<number>` token.
*/
export function getGithubRefSuggestions(
textBeforeCursor: string,
): { items: AutocompleteItem[]; prefix: string } | null {
const context = getGithubRefContext(textBeforeCursor);
if (!context) return null;
const kinds = context.qualifier
? GITHUB_REF_KINDS.filter(kind => kind.qualifier === context.qualifier)
: GITHUB_REF_KINDS;
const items: AutocompleteItem[] = kinds.map(kind => ({
value: `${kind.scheme}://${context.number}`,
label: `${kind.label} #${context.number}`,
description: kind.description,
}));
return { items, prefix: context.prefix };
}
@@ -579,10 +579,10 @@ export class InteractiveMode implements InteractiveModeContext {
this.retryLoader.stop();
this.retryLoader = undefined;
}
this.statusContainer.clear();
this.pendingMessagesContainer.clear();
this.statusContainer.disposeChildren();
this.pendingMessagesContainer.disposeChildren();
this.#cancelModelCycleClearTimer();
this.modelCycleContainer.clear();
this.modelCycleContainer.disposeChildren();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
@@ -2602,6 +2602,49 @@ export class InteractiveMode implements InteractiveModeContext {
}
}
#resolveLocalRoot(): string {
return resolveLocalUrlToPath("local://", {
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
getSessionId: () => this.sessionManager.getSessionId(),
});
}
async #copyLocalArtifactsForFreshSession(sourceRoot: string, destinationRoot: string): Promise<void> {
if (sourceRoot === destinationRoot) return;
let sourceRootStat: { isDirectory(): boolean };
try {
sourceRootStat = await fs.lstat(sourceRoot);
} catch (error) {
if (isEnoent(error)) return;
throw error;
}
if (!sourceRootStat.isDirectory()) return;
await fs.mkdir(destinationRoot, { recursive: true });
await this.#copyLocalArtifactEntries(sourceRoot, destinationRoot);
}
async #copyLocalArtifactEntries(sourceDir: string, destinationDir: string): Promise<void> {
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry.name);
const destinationPath = path.join(destinationDir, entry.name);
if (entry.isDirectory()) {
await fs.mkdir(destinationPath, { recursive: true });
await this.#copyLocalArtifactEntries(sourcePath, destinationPath);
continue;
}
if (entry.isFile()) {
await fs.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.copyFile(sourcePath, destinationPath);
}
}
}
async #approvePlan(
planContent: string,
options: {
@@ -2632,14 +2675,16 @@ export class InteractiveMode implements InteractiveModeContext {
});
if (!options.preserveContext) {
const oldLocalRoot = this.#resolveLocalRoot();
await this.handleClearCommand();
// The new session has a fresh local:// root — persist the approved plan there
// so `local://<slug>-plan.md` resolves correctly in the execution session.
const newLocalRoot = this.#resolveLocalRoot();
await this.#copyLocalArtifactsForFreshSession(oldLocalRoot, newLocalRoot);
const newLocalPath = resolveLocalUrlToPath(options.planFilePath, {
getArtifactsDir: () => this.sessionManager.getArtifactsDir(),
getSessionId: () => this.sessionManager.getSessionId(),
});
await Bun.write(newLocalPath, planContent);
await fs.mkdir(path.dirname(newLocalPath), { recursive: true });
await fs.writeFile(newLocalPath, planContent);
} else if (options.compactBeforeExecute) {
// Distill the plan-mode transcript before the execution turn is queued so
// the plan-approved synthetic prompt lands as a fresh cache anchor.
@@ -3626,7 +3671,7 @@ export class InteractiveMode implements InteractiveModeContext {
ensureLoadingAnimation(): void {
if (!this.loadingAnimation) {
this.#clearWorkingMessageAccentCache();
this.statusContainer.clear();
this.statusContainer.disposeChildren();
const messageColorFn = ((message: string) =>
renderWorkingMessage(message, this.#getWorkingMessageAccent())) as LoaderMessageColorFn & {
animated?: true;
@@ -3647,7 +3692,7 @@ export class InteractiveMode implements InteractiveModeContext {
);
this.statusContainer.addChild(this.loadingAnimation);
} else if (!this.statusContainer.children.includes(this.loadingAnimation)) {
this.statusContainer.clear();
this.statusContainer.disposeChildren();
this.statusContainer.addChild(this.loadingAnimation);
this.ui.requestRender();
}
@@ -3660,7 +3705,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.loadingAnimation = undefined;
this.#clearWorkingMessageAccentCache();
if (clearStatusContainer) {
this.statusContainer.clear();
this.statusContainer.disposeChildren();
}
}
@@ -4123,7 +4168,6 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.#btwController.dispose();
this.#omfgController.dispose();
this.chatContainer.clear();
this.renderInitialMessages({ clearTerminalHistory: true });
this.updateEditorBorderColor();
this.showStatus(
@@ -9,6 +9,7 @@ import {
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
import { isSettingsInitialized, settings } from "../config/settings";
import { applyEmojiCompletion, getEmojiSuggestions, isEmojiPrefix, tryEmojiInlineReplace } from "./emoji-autocomplete";
import { getGithubRefContext, getGithubRefSuggestions } from "./github-ref-autocomplete";
import {
applyInternalUrlCompletion,
getInternalUrlSuggestions,
@@ -94,6 +95,36 @@ function getPromptActionPrefix(textBeforeCursor: string): string | null {
return textBeforeCursor.slice(hashIndex);
}
function applyGithubRefCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } | null {
if (!getGithubRefContext(prefix)) return null;
const scheme: "pr" | "issue" | null = item.value.startsWith("pr://")
? "pr"
: item.value.startsWith("issue://")
? "issue"
: null;
if (!scheme) return { lines, cursorLine, cursorCol };
const currentLine = lines[cursorLine] || "";
const liveContext = getGithubRefContext(currentLine.slice(0, cursorCol));
if (!liveContext || (liveContext.qualifier && liveContext.qualifier !== scheme)) {
return { lines, cursorLine, cursorCol };
}
return applyInternalUrlCompletion(
lines,
cursorLine,
cursorCol,
{ ...item, value: `${scheme}://${liveContext.number}` },
liveContext.prefix,
);
}
export class PromptActionAutocompleteProvider implements AutocompleteProvider {
#commands: SlashCommand[];
#baseProvider: CombinedAutocompleteProvider;
@@ -129,6 +160,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
}
}
const githubRefSuggestions = getGithubRefSuggestions(textBeforeCursor);
if (githubRefSuggestions) return githubRefSuggestions;
const promptActionPrefix = getPromptActionPrefix(textBeforeCursor);
if (promptActionPrefix) {
const query = promptActionPrefix.slice(1).toLowerCase();
@@ -176,6 +209,8 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
cursorCol: number;
onApplied?: () => void;
} {
const githubRefCompletion = applyGithubRefCompletion(lines, cursorLine, cursorCol, item, prefix);
if (githubRefCompletion) return githubRefCompletion;
if (prefix.startsWith("#") && isPromptActionItem(item)) {
if (item.actionId === "undo") {
return {
@@ -52,7 +52,8 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`,
"| Hold `Space` | Speech-to-text (push-to-talk): hold to record, release to transcribe |",
`| \`${appKey(bindings, "app.agents.hub")}\` / \`${appKey(bindings, "app.session.observe")}\` / double-tap \`←\` (empty editor) | Open the agent hub |`,
"| `#` | Open prompt actions |",
"| `#<number>` | GitHub issue/PR reference (e.g. `#3164` → `pr://`/`issue://`) |",
"| `#` / `#<text>` | Prompt actions (copy / undo / move cursor) |",
"| `/` | Slash commands |",
"| `!` | Run bash command |",
"| `!!` | Run bash command (excluded from context) |",
@@ -581,7 +581,7 @@ export class UiHelpers {
} else {
this.ctx.resetTranscript();
}
this.ctx.pendingMessagesContainer.clear();
this.ctx.pendingMessagesContainer.disposeChildren();
this.ctx.pendingBashComponents = [];
this.ctx.pendingPythonComponents = [];
@@ -647,7 +647,7 @@ export class UiHelpers {
}
updatePendingMessagesDisplay(): void {
this.ctx.pendingMessagesContainer.clear();
this.ctx.pendingMessagesContainer.disposeChildren();
const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
const steeringMessages: Array<{ message: string; label: string }> = [];
+14 -8
View File
@@ -1,4 +1,5 @@
import workflowNotice from "../prompts/system/workflow-notice.md" with { type: "text" };
import { prompt } from "@oh-my-pi/pi-utils";
import workflowNoticeTemplate from "../prompts/system/workflow-notice.md" with { type: "text" };
import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
import { keywordInProse } from "./markdown-prose";
@@ -7,18 +8,23 @@ import { keywordInProse } from "./markdown-prose";
*
* Typing the standalone word in the input editor paints it with a warm
* amber→green gradient ({@link highlightWorkflow}); submitting a message that
* mentions it appends a hidden {@link WORKFLOW_NOTICE} that steers the model to
* author a deterministic multi-subagent workflow in eval cells (agent/parallel/
* pipeline). Matching is whitespace-delimited and case-sensitive (lowercase
* only) — "workflowz" triggers, but "workflowzed", "Workflowz", and
* "workflowz.ts" never do.
* mentions it appends a hidden workflow notice that steers the model to author
* a deterministic multi-subagent workflow through the active task schema.
* Matching is whitespace-delimited and case-sensitive (lowercase only) —
* "workflowz" triggers, but "workflowzed", "Workflowz", and "workflowz.ts"
* never do.
*/
// Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
const WORKFLOW_WORD = /(?<!\S)workflowz(?!\S)/;
/** Hidden system notice appended after a user message that mentions "workflowz". */
export const WORKFLOW_NOTICE: string = workflowNotice.trim();
/** WORKFLOW_NOTICE is the default hidden notice for sessions with batched task calls enabled. */
export const WORKFLOW_NOTICE: string = renderWorkflowNotice({ taskBatch: true });
/** renderWorkflowNotice renders the workflow notice for the active task schema. */
export function renderWorkflowNotice({ taskBatch }: { taskBatch: boolean }): string {
return prompt.render(workflowNoticeTemplate, { taskBatch }).trim();
}
/**
* Whether `text` contains the standalone keyword "workflowz"