Merge remote-tracking branch 'upstream/main' into feat/error-notify
This commit is contained in:
@@ -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 }> = [];
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user