feat(coding-agent): merge origin/main into model runtime facade

This commit is contained in:
Mario Zechner
2026-07-15 12:25:36 +02:00
119 changed files with 4275 additions and 631 deletions
@@ -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();