feat(coding-agent): merge origin/main into model runtime facade
This commit is contained in:
@@ -33,7 +33,7 @@ export class CustomEditor extends Editor {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for paste image keybinding
|
||||
// Check for clipboard paste keybinding
|
||||
if (this.keybindings.matches(data, "app.clipboard.pasteImage")) {
|
||||
this.onPasteImage?.();
|
||||
return;
|
||||
|
||||
@@ -175,6 +175,16 @@ export class LoginDialogComponent extends Container implements Focusable {
|
||||
});
|
||||
}
|
||||
|
||||
/** Show informational text before another login step. */
|
||||
showDetails(lines: string[]): void {
|
||||
this.contentContainer.clear();
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
for (const line of lines) {
|
||||
this.contentContainer.addChild(new Text(line, 1, 0));
|
||||
}
|
||||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
/** Show provider-owned information and links without starting an auth callback flow. */
|
||||
showInfo(message: string, links: readonly AuthInfoLink[] = [], showCloseHint = false): void {
|
||||
this.contentContainer.addChild(new Spacer(1));
|
||||
|
||||
@@ -61,6 +61,9 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
private scope: ModelScope = "all";
|
||||
private scopeText?: Text;
|
||||
private scopeHintText?: Text;
|
||||
private readonly refreshAbortController = new AbortController();
|
||||
private refreshTimeout?: ReturnType<typeof setTimeout>;
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
@@ -123,47 +126,20 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
// Add bottom border
|
||||
this.addChild(new DynamicBorder());
|
||||
|
||||
// Load models and do initial render
|
||||
this.loadModels().then(() => {
|
||||
if (initialSearchInput) {
|
||||
this.filterModels(initialSearchInput);
|
||||
} else {
|
||||
this.updateList();
|
||||
}
|
||||
// Request re-render after models are loaded
|
||||
this.tui.requestRender();
|
||||
});
|
||||
// Render the current snapshot immediately, then refresh in the background.
|
||||
this.loadModelsFromSnapshot();
|
||||
if (initialSearchInput) this.filterModels(initialSearchInput);
|
||||
else this.updateList();
|
||||
this.tui.requestRender();
|
||||
void this.refreshModels();
|
||||
}
|
||||
|
||||
private async loadModels(): Promise<void> {
|
||||
let models: ModelItem[];
|
||||
|
||||
// Refresh to pick up any changes to models.json
|
||||
await this.modelRuntime.refresh();
|
||||
|
||||
// Check for models.json errors
|
||||
const loadError = this.modelRuntime.getError();
|
||||
if (loadError) {
|
||||
this.errorMessage = loadError;
|
||||
}
|
||||
|
||||
// Load available models (built-in models still work even if models.json failed)
|
||||
try {
|
||||
const availableModels = await this.modelRuntime.getAvailable();
|
||||
models = availableModels.map((model: Model<any>) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.allModels = [];
|
||||
this.scopedModelItems = [];
|
||||
this.activeModels = [];
|
||||
this.filteredModels = [];
|
||||
this.errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return;
|
||||
}
|
||||
|
||||
private loadModelsFromSnapshot(): void {
|
||||
const models = this.modelRuntime.getAvailableSnapshot().map((model: Model<any>) => ({
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
model,
|
||||
}));
|
||||
this.allModels = this.sortModels(models);
|
||||
this.scopedModels = this.scopedModels.map((scoped) => {
|
||||
const refreshed = this.modelRuntime.getModel(scoped.model.provider, scoped.model.id);
|
||||
@@ -181,6 +157,37 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
currentIndex >= 0 ? currentIndex : Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
|
||||
}
|
||||
|
||||
private async refreshModels(): Promise<void> {
|
||||
const timeoutMs = 15_000;
|
||||
let timedOut = false;
|
||||
this.refreshTimeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
this.refreshAbortController.abort();
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const result = await this.modelRuntime.refresh({ signal: this.refreshAbortController.signal });
|
||||
if (this.closed) return;
|
||||
if (result.aborted && timedOut) {
|
||||
this.errorMessage = "Model refresh timed out; showing cached models.";
|
||||
} else if (result.errors.size > 0) {
|
||||
this.errorMessage = `Model refresh failed for: ${[...result.errors.keys()].join(", ")}`;
|
||||
} else {
|
||||
this.errorMessage = this.modelRuntime.getError();
|
||||
}
|
||||
this.loadModelsFromSnapshot();
|
||||
this.filterModels(this.searchInput.getValue());
|
||||
this.tui.requestRender();
|
||||
} finally {
|
||||
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
this.closed = true;
|
||||
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
|
||||
this.refreshAbortController.abort();
|
||||
}
|
||||
|
||||
private sortModels(models: ModelItem[]): ModelItem[] {
|
||||
const sorted = [...models];
|
||||
// Sort: current model first, then by provider
|
||||
@@ -316,6 +323,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
this.close();
|
||||
this.onCancelCallback();
|
||||
}
|
||||
// Pass everything else to search input
|
||||
@@ -326,6 +334,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
|
||||
}
|
||||
|
||||
private handleSelect(model: Model<any>): void {
|
||||
this.close();
|
||||
// Save as new default
|
||||
this.settingsManager.setDefaultModelAndProvider(model.provider, model.id);
|
||||
this.onSelectCallback(model);
|
||||
|
||||
@@ -122,6 +122,7 @@ class TreeList implements Component {
|
||||
|
||||
public onSelect?: (entryId: string) => void;
|
||||
public onCancel?: () => void;
|
||||
public onCopy?: (text: string | undefined) => void;
|
||||
public onLabelEdit?: (entryId: string, currentLabel: string | undefined) => void;
|
||||
|
||||
constructor(
|
||||
@@ -623,6 +624,11 @@ class TreeList implements Component {
|
||||
return this.filteredNodes[this.selectedIndex]?.node;
|
||||
}
|
||||
|
||||
copySelected(): void {
|
||||
const node = this.getSelectedNode();
|
||||
this.onCopy?.(node ? this.getEntryCopyText(node) : undefined);
|
||||
}
|
||||
|
||||
updateNodeLabel(entryId: string, label: string | undefined, labelTimestamp?: string): void {
|
||||
for (const flatNode of this.flatNodes) {
|
||||
if (flatNode.node.entry.id === entryId) {
|
||||
@@ -871,19 +877,49 @@ class TreeList implements Component {
|
||||
}
|
||||
|
||||
private extractContent(content: unknown): string {
|
||||
const maxLen = 200;
|
||||
if (typeof content === "string") return content.slice(0, maxLen);
|
||||
if (Array.isArray(content)) {
|
||||
let result = "";
|
||||
for (const c of content) {
|
||||
if (typeof c === "object" && c !== null && "type" in c && c.type === "text") {
|
||||
result += (c as { text: string }).text;
|
||||
if (result.length >= maxLen) return result.slice(0, maxLen);
|
||||
}
|
||||
return this.extractFullContent(content).slice(0, 200);
|
||||
}
|
||||
|
||||
private extractFullContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
let result = "";
|
||||
for (const block of content) {
|
||||
if (typeof block === "object" && block !== null && "type" in block && block.type === "text") {
|
||||
result += (block as { text: string }).text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return "";
|
||||
return result;
|
||||
}
|
||||
|
||||
private getEntryCopyText(node: SessionTreeNode): string | undefined {
|
||||
const entry = node.entry;
|
||||
let text: string | undefined;
|
||||
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
if (entry.message.role === "bashExecution") {
|
||||
text = entry.message.command;
|
||||
} else if ("content" in entry.message) {
|
||||
text = this.extractFullContent(entry.message.content);
|
||||
if (!text && entry.message.role === "assistant") {
|
||||
text = entry.message.errorMessage;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "custom_message":
|
||||
text = this.extractFullContent(entry.content);
|
||||
break;
|
||||
case "compaction":
|
||||
text = entry.summary;
|
||||
break;
|
||||
case "branch_summary":
|
||||
text = entry.summary;
|
||||
break;
|
||||
}
|
||||
|
||||
return text?.trim() ? text : undefined;
|
||||
}
|
||||
|
||||
private hasTextContent(content: unknown): boolean {
|
||||
@@ -990,6 +1026,8 @@ class TreeList implements Component {
|
||||
if (selected && this.onSelect) {
|
||||
this.onSelect(selected.node.entry.id);
|
||||
}
|
||||
} else if (kb.matches(keyData, "app.message.copy")) {
|
||||
this.copySelected();
|
||||
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
if (this.searchQuery) {
|
||||
this.searchQuery = "";
|
||||
@@ -1180,6 +1218,7 @@ const TREE_HELP_ITEMS: Array<{ keys: Keybinding[]; label: string; labelFirst?: b
|
||||
{ keys: ["tui.select.up", "tui.select.down"], label: "move" },
|
||||
{ keys: ["tui.editor.cursorLeft", "tui.editor.cursorRight"], label: "page" },
|
||||
{ keys: ["app.tree.foldOrUp", "app.tree.unfoldOrDown"], label: "branch" },
|
||||
{ keys: ["app.message.copy"], label: "copy" },
|
||||
{ keys: ["app.tree.editLabel"], label: "label" },
|
||||
{ keys: ["app.tree.toggleLabelTimestamp"], label: "label time" },
|
||||
{
|
||||
@@ -1292,6 +1331,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
||||
private labelInputContainer: Container;
|
||||
private treeContainer: Container;
|
||||
private onLabelChangeCallback?: (entryId: string, label: string | undefined) => void;
|
||||
public onCopy?: (text: string | undefined) => void;
|
||||
|
||||
// Focusable implementation - propagate to labelInput when active for IME cursor positioning
|
||||
private _focused = false;
|
||||
@@ -1324,6 +1364,7 @@ export class TreeSelectorComponent extends Container implements Focusable {
|
||||
this.treeList = new TreeList(tree, currentLeafId, maxVisibleLines, initialSelectedId, initialFilterMode);
|
||||
this.treeList.onSelect = onSelect;
|
||||
this.treeList.onCancel = onCancel;
|
||||
this.treeList.onCopy = (text) => this.onCopy?.(text);
|
||||
this.treeList.onLabelEdit = (entryId, currentLabel) => this.showLabelInput(entryId, currentLabel);
|
||||
|
||||
this.treeContainer = new Container();
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
getAgentDir,
|
||||
getAuthPath,
|
||||
getDebugLogPath,
|
||||
getDocsPath,
|
||||
getShareViewerUrl,
|
||||
VERSION,
|
||||
} from "../../config.ts";
|
||||
@@ -86,7 +87,7 @@ import { isInstallTelemetryEnabled } from "../../core/telemetry.ts";
|
||||
import type { TruncationResult } from "../../core/tools/truncate.ts";
|
||||
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
|
||||
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
|
||||
import { copyToClipboard } from "../../utils/clipboard.ts";
|
||||
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
|
||||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
import { parseGitUrl } from "../../utils/git.ts";
|
||||
import { getCwdRelativePath } from "../../utils/paths.ts";
|
||||
@@ -745,7 +746,7 @@ export class InteractiveMode {
|
||||
rawKeyHint("!!", "to run bash (no context)"),
|
||||
hint("app.message.followUp", "to queue follow-up"),
|
||||
hint("app.message.dequeue", "to edit all queued messages"),
|
||||
hint("app.clipboard.pasteImage", "to paste image"),
|
||||
hint("app.clipboard.pasteImage", "to paste image (with text fallback)"),
|
||||
rawKeyHint("drop files", "to attach"),
|
||||
].join("\n");
|
||||
const compactInstructions = [
|
||||
@@ -2558,6 +2559,7 @@ export class InteractiveMode {
|
||||
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
|
||||
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
|
||||
this.defaultEditor.onAction("app.editor.external", () => this.openExternalEditor());
|
||||
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand());
|
||||
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
|
||||
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
|
||||
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
|
||||
@@ -2573,29 +2575,33 @@ export class InteractiveMode {
|
||||
}
|
||||
};
|
||||
|
||||
// Handle clipboard image paste (triggered on Ctrl+V)
|
||||
// Handle clipboard paste (triggered on Ctrl+V). Images are attached by path;
|
||||
// otherwise, paste plain text from the system clipboard.
|
||||
this.defaultEditor.onPasteImage = () => {
|
||||
this.handleClipboardImagePaste();
|
||||
void this.handleClipboardPaste();
|
||||
};
|
||||
}
|
||||
|
||||
private async handleClipboardImagePaste(): Promise<void> {
|
||||
private async handleClipboardPaste(): Promise<void> {
|
||||
try {
|
||||
const image = await readClipboardImage();
|
||||
if (!image) {
|
||||
if (image) {
|
||||
const tmpDir = os.tmpdir();
|
||||
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
|
||||
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
|
||||
const filePath = path.join(tmpDir, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
||||
|
||||
this.editor.insertTextAtCursor?.(filePath);
|
||||
this.ui.requestRender();
|
||||
return;
|
||||
}
|
||||
|
||||
// Write to temp file
|
||||
const tmpDir = os.tmpdir();
|
||||
const ext = extensionForImageMimeType(image.mimeType) ?? "png";
|
||||
const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
|
||||
const filePath = path.join(tmpDir, fileName);
|
||||
fs.writeFileSync(filePath, Buffer.from(image.bytes));
|
||||
|
||||
// Insert file path directly
|
||||
this.editor.insertTextAtCursor?.(filePath);
|
||||
this.ui.requestRender();
|
||||
const text = await readClipboardText();
|
||||
if (text) {
|
||||
this.editor.insertTextAtCursor?.(text);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore clipboard errors (may not have permission, etc.)
|
||||
}
|
||||
@@ -4681,6 +4687,18 @@ export class InteractiveMode {
|
||||
initialSelectedId,
|
||||
initialFilterMode,
|
||||
);
|
||||
selector.onCopy = async (text) => {
|
||||
if (!text) {
|
||||
this.showError("Selected entry has no text to copy");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await copyToClipboard(text);
|
||||
this.showStatus("Copied selected message to clipboard");
|
||||
} catch (error) {
|
||||
this.showError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
};
|
||||
return { component: selector, focus: selector };
|
||||
});
|
||||
}
|
||||
@@ -4851,8 +4869,8 @@ export class InteractiveMode {
|
||||
}
|
||||
|
||||
private showLoginAuthTypeSelector(providerOptions?: AuthSelectorProvider[]): void {
|
||||
const subscriptionLabel = "Use a subscription";
|
||||
const apiKeyLabel = "Use an API key";
|
||||
const subscriptionLabel = "Sign in with an account";
|
||||
const apiKeyLabel = "Sign in with an API key";
|
||||
const availableAuthTypes = providerOptions
|
||||
? new Set(providerOptions.map((provider) => provider.authType))
|
||||
: new Set<AuthSelectorProvider["authType"]>(["oauth", "api_key"]);
|
||||
@@ -5083,6 +5101,14 @@ export class InteractiveMode {
|
||||
providerName,
|
||||
);
|
||||
|
||||
if (providerId === "amazon-bedrock") {
|
||||
dialog.showDetails([
|
||||
theme.fg("text", "You can also use an AWS profile, IAM keys, or role-based credentials."),
|
||||
theme.fg("muted", "See:"),
|
||||
theme.fg("accent", ` ${path.join(getDocsPath(), "providers.md")}`),
|
||||
]);
|
||||
}
|
||||
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(dialog);
|
||||
this.ui.setFocus(dialog);
|
||||
@@ -5698,6 +5724,7 @@ export class InteractiveMode {
|
||||
const toggleThinking = this.getAppKeyDisplay("app.thinking.toggle");
|
||||
const externalEditor = this.getAppKeyDisplay("app.editor.external");
|
||||
const cycleModelBackward = this.getAppKeyDisplay("app.model.cycleBackward");
|
||||
const copyMessage = this.getAppKeyDisplay("app.message.copy");
|
||||
const followUp = this.getAppKeyDisplay("app.message.followUp");
|
||||
const dequeue = this.getAppKeyDisplay("app.message.dequeue");
|
||||
const pasteImage = this.getAppKeyDisplay("app.clipboard.pasteImage");
|
||||
@@ -5741,9 +5768,10 @@ export class InteractiveMode {
|
||||
| \`${expandTools}\` | Toggle tool output expansion |
|
||||
| \`${toggleThinking}\` | Toggle thinking block visibility |
|
||||
| \`${externalEditor}\` | Edit message in external editor |
|
||||
| \`${copyMessage}\` | Copy last assistant message |
|
||||
| \`${followUp}\` | Queue follow-up message |
|
||||
| \`${dequeue}\` | Restore queued messages |
|
||||
| \`${pasteImage}\` | Paste image from clipboard |
|
||||
| \`${pasteImage}\` | Paste image or text from clipboard |
|
||||
| \`/\` | Slash commands |
|
||||
| \`!\` | Run bash command |
|
||||
| \`!!\` | Run bash command (excluded from context) |
|
||||
|
||||
Reference in New Issue
Block a user