import type { Theme } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui"; import { cycleNumberOption, cycleOption, type CycleDirection } from "./settings-core.ts"; import type { ClusterConfig, ClusterLevel } from "./types.ts"; const LEVELS: ClusterLevel[] = ["high", "medium", "low"]; const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; const WORKER_TOOLS = ["read", "grep", "find", "ls", "bash", "edit", "write"]; const REVIEWER_TOOLS = ["read", "grep", "find", "ls"]; const FOLLOW_MAIN = "跟随主 agent"; type SettingsView = "main" | "group" | "models" | "tools"; type Group = ClusterLevel | "reviewer" | "cluster"; type SettingRow = { id: string; label: string; description: string; kind: "model" | "thinking" | "tools" | "number" | "boolean" | "action"; level?: ClusterLevel; }; export interface AvailableModel { value: string; label: string; description: string; thinkingLevels: string[]; } export interface ClusterSettingsCallbacks { onSave(config: ClusterConfig): Promise; onClearLearningEvidence(): Promise<"cleared" | "empty" | "cancelled">; onClose(): void; } export class ClusterSettingsComponent implements Component { private readonly mainRows: Array<{ group: Group; label: string; description: string }> = [ { group: "high", label: "HIGH", description: "最高等级 worker 模型、推理、工具和超时" }, { group: "medium", label: "MEDIUM", description: "中等级 worker 模型、推理、工具和超时" }, { group: "low", label: "LOW", description: "低等级 worker 模型、推理、工具和超时" }, { group: "reviewer", label: "REVIEWER", description: "审核器模型、推理、只读工具和超时" }, { group: "cluster", label: "CLUSTER", description: "并发数、任务数、重试次数和学习参数" }, ]; private view: SettingsView = "main"; private selectedIndex = 0; private mainSelectedIndex = 0; private readonly groupSelectedIndexes: Record = { high: 0, medium: 0, low: 0, reviewer: 0, cluster: 0 }; private selectedGroup: Group = "high"; private subIndex = 0; private toolsTarget: ClusterLevel | "reviewer" = "low"; private error = ""; private message = ""; private actionPending = false; private persistQueue = Promise.resolve(); constructor( private readonly tui: TUI, private readonly theme: Theme, private readonly config: ClusterConfig, private readonly models: AvailableModel[], private readonly reviewerThinkingLevels: string[], private readonly callbacks: ClusterSettingsCallbacks, ) {} render(width: number): string[] { const contentWidth = Math.max(1, width - 2); const content = this.view === "group" ? this.renderGroup(contentWidth) : this.view === "models" ? this.renderModels(contentWidth) : this.view === "tools" ? this.renderTools(contentWidth) : this.renderMain(contentWidth); if (width < 3) return content.map((line) => truncateToWidth(line, width, "", false)); const border = "─".repeat(contentWidth); const title = "Subagent Cluster Settings"; const titleWidth = visibleWidth(title); const topBorder = contentWidth >= titleWidth + 4 ? this.theme.fg("border", "╭─ ") + this.theme.fg("accent", title) + this.theme.fg("border", ` ${"─".repeat(contentWidth - titleWidth - 3)}╮`) : this.theme.fg("border", `╭${border}╮`); return [ topBorder, ...content.map((line) => this.theme.fg("border", "│") + truncateToWidth(line, contentWidth, "", true) + this.theme.fg("border", "│")), this.theme.fg("border", `╰${border}╯`), ]; } handleInput(data: string): void { if (this.actionPending) return; if (this.view === "models") return this.handleModels(data); if (this.view === "tools") return this.handleTools(data); if (matchesKey(data, Key.escape)) { if (this.view === "group") { this.groupSelectedIndexes[this.selectedGroup] = this.selectedIndex; this.selectedIndex = this.mainSelectedIndex; this.view = "main"; } else this.callbacks.onClose(); this.tui.requestRender(); return; } const rows = this.view === "main" ? this.mainRows : this.groupRows(this.selectedGroup); if (matchesKey(data, Key.up) || data === "k") { this.selectedIndex = this.selectedIndex === 0 ? rows.length - 1 : this.selectedIndex - 1; this.tui.requestRender(); return; } if (matchesKey(data, Key.down) || data === "j") { this.selectedIndex = this.selectedIndex === rows.length - 1 ? 0 : this.selectedIndex + 1; this.tui.requestRender(); return; } if (this.view === "group" && (matchesKey(data, Key.left) || matchesKey(data, Key.right))) { const row = rows[this.selectedIndex] as SettingRow | undefined; this.adjustRow(row, matchesKey(data, Key.left) ? -1 : 1); this.tui.requestRender(); return; } if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) this.activateRow(rows[this.selectedIndex]); } private adjustRow(row: SettingRow | undefined, direction: CycleDirection): void { if (row?.kind === "thinking") this.cycleThinking(row.id, direction); else if (row?.kind === "boolean") this.cycleBoolean(row.id, direction); else if (row?.kind === "number") this.cycleNumber(row.id, direction); } private activateRow(row: SettingRow | { group: Group } | undefined): void { if (!row) return; if ("group" in row) { this.mainSelectedIndex = this.selectedIndex; this.selectedGroup = row.group; this.selectedIndex = this.groupSelectedIndexes[row.group]; this.view = "group"; this.tui.requestRender(); return; } if (row.kind === "model") { const options = this.modelOptions(row); this.view = "models"; this.subIndex = Math.max(0, options.findIndex((model) => model.value === this.modelValue(row.id))); this.tui.requestRender(); return; } if (row.kind === "tools") { this.toolsTarget = row.id === "reviewer.tools" ? "reviewer" : row.level ?? "low"; this.view = "tools"; this.subIndex = 0; this.tui.requestRender(); return; } if (row.kind === "action") void this.clearLearningEvidence(); } private groupRows(group: Group): SettingRow[] { if (group === "cluster") { return [ { id: "maxConcurrency", label: "Max concurrency", description: "同时运行的最大 worker 数量", kind: "number" }, { id: "maxTasks", label: "Max tasks", description: "单次集群允许的最大任务数量", kind: "number" }, { id: "maxRetriesPerLevel", label: "Retries per level", description: "自动升级前的同等级重试次数", kind: "number" }, { id: "learning.enabled", label: "Learning", description: "是否根据全局学习证据调整任务最低起始等级", kind: "boolean" }, { id: "learning.taskTypeUpgradeThreshold", label: "Task type threshold", description: "同一 taskType 需要多少个不同运行的有效升级证据", kind: "number" }, { id: "learning.historyRetentionDays", label: "History retention", description: "学习证据的有效天数", kind: "number" }, { id: "learning.clearEvidence", label: "Clear learning evidence", description: "清除所有项目共享的全部学习证据,此操作不可撤销", kind: "action" }, ]; } if (group === "reviewer") { return [ { id: "reviewer.model", label: "Model", description: "审核器模型;留空表示跟随主 agent", kind: "model" }, { id: "reviewer.thinkingLevel", label: "Thinking level", description: "按审核器模型能力显示可用推理模式", kind: "thinking" }, { id: "reviewer.tools", label: "Tools", description: "审核器只能选择只读工具", kind: "tools" }, { id: "reviewer.timeoutMs", label: "Timeout", description: "单次审核超时时间", kind: "number" }, ]; } return [ { id: `${group}.model`, label: "Model", description: "worker 使用的可用模型", kind: "model", level: group }, { id: `${group}.thinkingLevel`, label: "Thinking level", description: "按当前模型能力显示可用推理模式", kind: "thinking", level: group }, { id: `${group}.tools`, label: "Tools", description: "worker 可调用的工具", kind: "tools", level: group }, { id: `${group}.timeoutMs`, label: "Timeout", description: "单次 worker 超时时间", kind: "number", level: group }, ]; } private handleModels(data: string): void { const row = this.groupRows(this.selectedGroup)[this.selectedIndex]; const options = this.modelOptions(row); if (matchesKey(data, Key.escape)) { this.view = "group"; this.tui.requestRender(); return; } if (matchesKey(data, Key.up) || data === "k") this.subIndex = this.subIndex === 0 ? Math.max(0, options.length - 1) : this.subIndex - 1; else if (matchesKey(data, Key.down) || data === "j") this.subIndex = this.subIndex === options.length - 1 ? 0 : this.subIndex + 1; else if (matchesKey(data, Key.enter) && options[this.subIndex]) { const selected = options[this.subIndex]; if (row?.id === "reviewer.model") { this.config.reviewer.model = selected.value || undefined; this.normalizeThinking("reviewer.thinkingLevel"); } else if (row?.level) { this.config.levels[row.level].model = selected.value; this.normalizeThinking(`${row.level}.thinkingLevel`); } this.persist(); this.view = "group"; } this.tui.requestRender(); } private handleTools(data: string): void { const tools = this.toolsForTarget(); if (matchesKey(data, Key.escape)) { this.view = "group"; this.tui.requestRender(); return; } if (matchesKey(data, Key.up) || data === "k") this.subIndex = this.subIndex === 0 ? tools.length - 1 : this.subIndex - 1; else if (matchesKey(data, Key.down) || data === "j") this.subIndex = this.subIndex === tools.length - 1 ? 0 : this.subIndex + 1; else if (matchesKey(data, Key.space)) { const tool = tools[this.subIndex]; const selected = this.selectedTools(); this.setSelectedTools(selected.includes(tool) ? selected.filter((item) => item !== tool) : [...selected, tool]); this.persist(); } else if (matchesKey(data, Key.enter)) this.view = "group"; this.tui.requestRender(); } private modelOptions(row: SettingRow | undefined): AvailableModel[] { if (row?.id !== "reviewer.model") return this.models; return [{ value: "", label: "跟随主 agent", description: `thinking: ${this.reviewerThinkingLevels.join(", ")}`, thinkingLevels: this.reviewerThinkingLevels }, ...this.models]; } private toolsForTarget(): string[] { return this.toolsTarget === "reviewer" ? REVIEWER_TOOLS : WORKER_TOOLS; } private selectedTools(): string[] { return this.toolsTarget === "reviewer" ? [...(this.config.reviewer.tools ?? [])] : [...(this.config.levels[this.toolsTarget].tools ?? [])]; } private setSelectedTools(tools: string[]): void { if (this.toolsTarget === "reviewer") this.config.reviewer.tools = tools.filter((tool) => REVIEWER_TOOLS.includes(tool)); else this.config.levels[this.toolsTarget].tools = tools; } private thinkingValues(id: string): string[] { if (id === "reviewer.thinkingLevel") { const model = this.models.find((item) => item.value === this.config.reviewer.model); const levels = model?.thinkingLevels.length ? model.thinkingLevels : this.reviewerThinkingLevels.length ? this.reviewerThinkingLevels : ["off"]; return this.config.reviewer.model ? levels : [FOLLOW_MAIN, ...levels]; } const [level] = id.split(".") as [ClusterLevel]; const model = this.models.find((item) => item.value === this.config.levels[level]?.model); return model?.thinkingLevels.length ? model.thinkingLevels : THINKING_LEVELS; } private cycleThinking(id: string, direction: CycleDirection): void { const values = this.thinkingValues(id); const current = this.value(id); const next = cycleOption(values, current, direction) ?? "off"; if (id === "reviewer.thinkingLevel") this.config.reviewer.thinkingLevel = next === FOLLOW_MAIN ? undefined : next as ClusterConfig["reviewer"]["thinkingLevel"]; else { const [level] = id.split(".") as [ClusterLevel]; this.config.levels[level].thinkingLevel = next as ClusterConfig["levels"][ClusterLevel]["thinkingLevel"]; } this.persist(); } private normalizeThinking(id: string): void { const values = this.thinkingValues(id); if (values.includes(this.value(id))) return; const value = values[0] ?? "off"; if (id === "reviewer.thinkingLevel") this.config.reviewer.thinkingLevel = value === FOLLOW_MAIN ? undefined : value as ClusterConfig["reviewer"]["thinkingLevel"]; else { const [level] = id.split(".") as [ClusterLevel]; this.config.levels[level].thinkingLevel = value as ClusterConfig["levels"][ClusterLevel]["thinkingLevel"]; } } private numberOptions(id: string): number[] { if (id === "maxConcurrency") return [1, 2, 4, 8, 16]; if (id === "maxTasks") return [1, 4, 8, 16, 32]; if (id === "maxRetriesPerLevel") return [0, 1, 2, 3]; if (id === "learning.taskTypeUpgradeThreshold") return [1, 2, 3, 4, 5, 10]; if (id === "learning.historyRetentionDays") return [1, 7, 14, 30, 60, 90, 180, 365]; return [1_000, 5_000, 10_000, 30_000, 60_000, 120_000, 300_000, 600_000, 1_200_000, 1_800_000, 3_600_000, 10_800_000]; } private rawNumberValue(id: string): number { if (id === "maxConcurrency") return this.config.maxConcurrency; if (id === "maxTasks") return this.config.maxTasks; if (id === "maxRetriesPerLevel") return this.config.maxRetriesPerLevel; if (id === "learning.taskTypeUpgradeThreshold") return this.config.learning.taskTypeUpgradeThreshold; if (id === "learning.historyRetentionDays") return this.config.learning.historyRetentionDays; if (id === "reviewer.timeoutMs") return this.config.reviewer.timeoutMs ?? 180_000; const [level] = id.split(".") as [ClusterLevel]; return this.config.levels[level].timeoutMs ?? 900_000; } private cycleBoolean(id: string, direction: CycleDirection): void { if (id !== "learning.enabled") return; this.config.learning.enabled = cycleOption([false, true], this.config.learning.enabled, direction) ?? this.config.learning.enabled; this.persist(); } private async clearLearningEvidence(): Promise { this.actionPending = true; this.error = ""; this.message = ""; this.tui.requestRender(); try { const result = await this.callbacks.onClearLearningEvidence(); this.message = result === "cleared" ? "已清除全部全局学习证据" : result === "empty" ? "当前没有全局学习证据" : "已取消清除全局学习证据"; } catch (error) { this.error = error instanceof Error ? error.message : String(error); } finally { this.actionPending = false; this.tui.requestRender(); } } private cycleNumber(id: string, direction: CycleDirection): void { const current = this.rawNumberValue(id); const options = this.numberOptions(id); this.setNumber(id, cycleNumberOption(options, current, direction) ?? current); this.persist(); } private setNumber(id: string, value: number): void { if (id === "maxConcurrency") this.config.maxConcurrency = value; else if (id === "maxTasks") this.config.maxTasks = value; else if (id === "maxRetriesPerLevel") this.config.maxRetriesPerLevel = value; else if (id === "learning.taskTypeUpgradeThreshold") this.config.learning.taskTypeUpgradeThreshold = value; else if (id === "learning.historyRetentionDays") this.config.learning.historyRetentionDays = value; else if (id === "reviewer.timeoutMs") this.config.reviewer.timeoutMs = value; else { const [level] = id.split(".") as [ClusterLevel]; this.config.levels[level].timeoutMs = value; } } private formatDuration(value: number): string { let seconds = Math.max(1, Math.round(value / 1_000)); const hours = Math.floor(seconds / 3_600); seconds %= 3_600; const minutes = Math.floor(seconds / 60); seconds %= 60; return [hours ? `${hours}h` : "", minutes ? `${minutes}m` : "", seconds ? `${seconds}s` : ""].filter(Boolean).join(" "); } private modelValue(id: string): string { return id === "reviewer.model" ? this.config.reviewer.model ?? "" : this.value(id); } private value(id: string): string { if (id === "reviewer.model") return this.config.reviewer.model ?? FOLLOW_MAIN; if (id === "reviewer.thinkingLevel") return this.config.reviewer.thinkingLevel ?? FOLLOW_MAIN; if (id === "reviewer.tools") return (this.config.reviewer.tools ?? []).join(", "); if (id === "maxConcurrency") return String(this.config.maxConcurrency); if (id === "maxTasks") return String(this.config.maxTasks); if (id === "maxRetriesPerLevel") return String(this.config.maxRetriesPerLevel); if (id === "learning.enabled") return this.config.learning.enabled ? "开启" : "关闭"; if (id === "learning.taskTypeUpgradeThreshold") return String(this.config.learning.taskTypeUpgradeThreshold); if (id === "learning.historyRetentionDays") return `${this.config.learning.historyRetentionDays} 天`; if (id === "learning.clearEvidence") return this.actionPending ? "清除中…" : "Enter 清除"; if (id === "reviewer.timeoutMs") return this.formatDuration(this.config.reviewer.timeoutMs ?? 180_000); const [level, field] = id.split(".") as [ClusterLevel, "model" | "thinkingLevel" | "tools" | "timeoutMs"]; if (field === "tools") return (this.config.levels[level].tools ?? []).join(", "); if (field === "timeoutMs") return this.formatDuration(this.config.levels[level].timeoutMs ?? 900_000); return String(this.config.levels[level][field] ?? ""); } private persist(): void { const snapshot = JSON.parse(JSON.stringify(this.config)) as ClusterConfig; this.persistQueue = this.persistQueue.then(() => this.callbacks.onSave(snapshot)).catch((error) => { this.error = error instanceof Error ? error.message : String(error); this.tui.requestRender(); }); } private mainValue(group: Group): string { if (group === "cluster") return `学习${this.config.learning.enabled ? "开启" : "关闭"} · Enter 进入`; if (group === "reviewer") return this.config.reviewer.model ?? FOLLOW_MAIN; return this.config.levels[group].model; } private renderMain(width: number): string[] { const lines = [this.theme.fg("muted", "全局配置 · ~/.pi/agent/subagent-cluster.json"), this.theme.fg("border", "─".repeat(width))]; const labelWidth = Math.max(...this.mainRows.map((row) => row.label.length)); for (let index = 0; index < this.mainRows.length; index++) { const row = this.mainRows[index]; const selected = index === this.selectedIndex; const prefix = selected ? this.theme.fg("accent", "▸ ") : " "; const label = row.label.padEnd(labelWidth, " "); lines.push(truncateToWidth(`${prefix}${selected ? this.theme.fg("accent", label) : label} ${this.theme.fg("muted", this.mainValue(row.group))}`, width, "", false)); if (selected) lines.push(this.theme.fg("dim", ` ${row.description}`)); } lines.push(this.theme.fg("border", "─".repeat(width)), this.theme.fg("dim", "↑/↓ 选择 · Enter 进入二级菜单 · Esc 返回")); return lines; } private renderGroup(width: number): string[] { const group = this.mainRows.find((row) => row.group === this.selectedGroup); const rows = this.groupRows(this.selectedGroup); const labelWidth = Math.max(...rows.map((row) => row.label.length)); const lines = [this.theme.fg("accent", this.theme.bold(`${group?.label ?? this.selectedGroup} Settings`)), this.theme.fg("dim", group?.description ?? ""), this.theme.fg("border", "─".repeat(width))]; for (let index = 0; index < rows.length; index++) { const row = rows[index]; const selected = index === this.selectedIndex; const prefix = selected ? this.theme.fg("accent", "▸ ") : " "; const label = row.label.padEnd(labelWidth, " "); lines.push(truncateToWidth(`${prefix}${selected ? this.theme.fg("accent", label) : label} ${this.theme.fg("muted", this.value(row.id))}`, width, "", false)); if (selected) { const hint = row.kind === "thinking" ? `←/→ 切换 · 可选:${this.thinkingValues(row.id).join(", ")}` : row.kind === "number" || row.kind === "boolean" ? "←/→ 切换" : row.kind === "action" ? "Enter 执行" : "Enter 打开"; lines.push(this.theme.fg("dim", ` ${row.description} · ${hint}`)); } } if (this.error) lines.push(this.theme.fg("error", this.error)); if (this.message) lines.push(this.theme.fg("success", this.message)); lines.push(this.theme.fg("border", "─".repeat(width)), this.theme.fg("dim", "↑/↓ 选择 · ←/→ 调整 · Enter 打开或执行 · Esc 返回 · 每次修改自动保存")); return lines; } private renderModels(width: number): string[] { const row = this.groupRows(this.selectedGroup)[this.selectedIndex]; const options = this.modelOptions(row); const current = this.modelValue(row?.id ?? ""); const lines = [this.theme.fg("accent", this.theme.bold(`${row?.label ?? "Model"} · 选择可用模型`)), this.theme.fg("border", "─".repeat(width))]; for (let index = 0; index < options.length; index++) { const model = options[index]; const selected = index === this.subIndex; const marker = selected ? this.theme.fg("accent", "▸ ") : " "; const currentMark = model.value === current ? this.theme.fg("success", " ✓") : ""; lines.push(truncateToWidth(`${marker}${model.label}${currentMark} ${this.theme.fg("dim", model.description)}`, width, "", false)); } lines.push(this.theme.fg("border", "─".repeat(width)), this.theme.fg("dim", "↑/↓ 选择 · Enter 确认 · Esc 返回")); return lines; } private renderTools(width: number): string[] { const tools = this.toolsForTarget(); const selectedTools = this.selectedTools(); const title = this.toolsTarget === "reviewer" ? "REVIEWER tools · 只读多选" : `${this.toolsTarget.toUpperCase()} tools · 多选工具`; const lines = [this.theme.fg("accent", this.theme.bold(title)), this.theme.fg("border", "─".repeat(width))]; for (let index = 0; index < tools.length; index++) { const tool = tools[index]; const selected = index === this.subIndex; const checked = selectedTools.includes(tool) ? this.theme.fg("success", "[x]") : this.theme.fg("dim", "[ ]"); lines.push(truncateToWidth(`${selected ? this.theme.fg("accent", "▸ ") : " "}${checked} ${tool}`, width, "", false)); } lines.push(this.theme.fg("border", "─".repeat(width)), this.theme.fg("dim", "↑/↓ 选择 · Space 开关 · Enter 返回 · 每次修改自动保存")); return lines; } invalidate(): void {} }