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();
|
||||
|
||||
Reference in New Issue
Block a user