gentle-pi 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -7
- package/assets/agents/gentle-ai-worker.md +1 -1
- package/assets/design-canvas/canvas.css +648 -0
- package/assets/design-canvas/canvas.js +1497 -0
- package/assets/design-canvas/index.html +88 -0
- package/assets/design-canvas/inspector.js +290 -0
- package/assets/orchestrator-delegation-reference.md +2 -2
- package/assets/orchestrator-verification.md +1 -1
- package/assets/orchestrator.md +2 -2
- package/bin/engram-guard +1 -1
- package/bin/gentle-shell.mjs +138 -155
- package/docs/bundled-install.md +47 -42
- package/docs/delegated-verification.md +6 -0
- package/docs/gentle-agents-activity.md +5 -1
- package/docs/gentle-music.md +82 -0
- package/docs/gentle-shell.md +80 -10
- package/docs/install-wizard.md +30 -6
- package/docs/permission-modes.md +7 -2
- package/docs/readme-reference.md +72 -55
- package/docs/session-profile-format.md +26 -0
- package/docs/voice.md +259 -0
- package/docs/yolo-mode.md +17 -2
- package/extensions/ask-user-choice.ts +4 -1
- package/extensions/gentle-agents.ts +187 -40
- package/extensions/gentle-ai.ts +1765 -1185
- package/extensions/gentle-design.ts +158 -0
- package/extensions/gentle-music.ts +354 -0
- package/extensions/gentle-shell.ts +418 -79
- package/extensions/gentle-voice.ts +11 -0
- package/extensions/session-scratch.ts +41 -22
- package/extensions/skill-registry.ts +63 -8
- package/lib/agent-profiles.ts +26 -0
- package/lib/agents-config.ts +35 -0
- package/lib/agents-protocol.ts +57 -1
- package/lib/agents-runner.ts +52 -3
- package/lib/agents-session-transport.ts +101 -6
- package/lib/agents-widget.ts +14 -1
- package/lib/auto-permission-memory.ts +48 -18
- package/lib/auto-permission-runtime.ts +21 -6
- package/lib/clipboard-image.ts +147 -0
- package/lib/command-palette-catalog.ts +3 -2
- package/lib/commit-slice-signal.ts +1 -1
- package/lib/context-backstop.ts +36 -0
- package/lib/design-canvas-server.ts +947 -0
- package/lib/design-comments.ts +437 -0
- package/lib/design-handoffs.ts +313 -0
- package/lib/design-open-browser.ts +58 -0
- package/lib/design-sources.ts +120 -0
- package/lib/external-editor.ts +31 -0
- package/lib/gentle-design.ts +513 -0
- package/lib/gentle-shell-bundled.ts +141 -57
- package/lib/gentle-shell-launcher.ts +11 -4
- package/lib/gentle-shell-startup-update.ts +61 -9
- package/lib/gentle-shell-uninstall.ts +33 -1
- package/lib/image-attachments.ts +150 -0
- package/lib/inprocess-reviewer.ts +33 -2
- package/lib/jobs-sidebar-state.ts +22 -3
- package/lib/jobs-widget.ts +112 -0
- package/lib/nan-provider.ts +3 -0
- package/lib/native-review-cli.ts +11 -4
- package/lib/notification-audio-native.ts +149 -13
- package/lib/notification-audio.ts +4 -0
- package/lib/notification-pulse-worker.ts +82 -18
- package/lib/notification-service.ts +5 -1
- package/lib/orchestrator-presence.ts +78 -8
- package/lib/orchestrator-scope.ts +10 -0
- package/lib/orchestrator-state.ts +11 -3
- package/lib/permission-mode-policy.ts +2 -2
- package/lib/review-host-relay.ts +11 -2
- package/lib/review-reminder-receipt.ts +24 -1
- package/lib/session-profile-append-controller.ts +713 -0
- package/lib/session-profile-disk-reader.ts +182 -0
- package/lib/session-profile-freeze.ts +2 -2
- package/lib/session-profile-persistence.ts +15 -7
- package/lib/session-scratch.ts +52 -3
- package/lib/session-worktree-registry.ts +7 -0
- package/lib/shell-bar.ts +6 -7
- package/lib/shell-changes.ts +81 -5
- package/lib/shell-prompt.ts +4 -4
- package/lib/shell-sidebar-layout.ts +27 -7
- package/lib/shell-todo.ts +1 -1
- package/lib/verifier-admission.ts +5 -4
- package/lib/vim-editor-adapter.ts +14 -10
- package/lib/voice-artifacts.ts +188 -0
- package/lib/voice-command.ts +576 -0
- package/lib/voice-install.ts +455 -0
- package/lib/voice-platform.ts +99 -0
- package/lib/voice-push-to-talk.ts +262 -0
- package/lib/voice-session.ts +460 -0
- package/lib/voice-settings.ts +50 -0
- package/lib/voice-ui.ts +158 -0
- package/lib/yolo-session-policy.ts +58 -22
- package/package.json +1 -1
- package/runtime/gentle-shell-bundled.mjs +140 -56
- package/runtime/gentle-shell-launcher.mjs +11 -4
- package/runtime/gentle-shell-startup-update.mjs +61 -9
- package/runtime/gentle-shell-uninstall.mjs +33 -1
- package/runtime/native-review-cli.mjs +11 -4
- package/scripts/bundled-engram-migration.mjs +0 -6
- package/scripts/bundled-install.mjs +209 -13
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/installer-downloads.mjs +31 -7
- package/scripts/installer-preflight.mjs +6 -4
- package/scripts/installer-windows.mjs +37 -7
- package/scripts/main-channel.mjs +91 -17
- package/scripts/pi-update.mjs +37 -7
- package/scripts/verify-package-files.mjs +26 -2
- package/scripts/voice-worker.cjs +476 -0
- package/skills/design/SKILL.md +110 -0
- package/tests/agent-profiles.test.ts +24 -0
- package/tests/agents-config.test.ts +61 -0
- package/tests/agents-fake-child.ts +8 -1
- package/tests/agents-protocol.test.ts +32 -0
- package/tests/agents-runner.test.ts +226 -5
- package/tests/agents-session-transport.test.ts +53 -3
- package/tests/ask-user-choice.test.ts +30 -0
- package/tests/auto-mode.test.ts +17 -0
- package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
- package/tests/auto-permission-runtime.test.ts +85 -1
- package/tests/autonomous-guard.test.ts +20 -0
- package/tests/bundled-install.test.ts +276 -5
- package/tests/clipboard-image.test.ts +114 -0
- package/tests/command-palette.test.ts +4 -4
- package/tests/context-backstop.test.ts +70 -4
- package/tests/delegate-for-reason-contract.test.ts +2 -1
- package/tests/design-canvas-assets.test.ts +710 -0
- package/tests/design-canvas-server.test.ts +1096 -0
- package/tests/design-comments.test.ts +201 -0
- package/tests/design-handoffs.test.ts +282 -0
- package/tests/design-open-browser.test.ts +70 -0
- package/tests/design-sources.test.ts +132 -0
- package/tests/engram-safety.test.ts +132 -57
- package/tests/external-editor.test.ts +84 -0
- package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
- package/tests/gentle-agents.test.ts +369 -24
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +51 -51
- package/tests/gentle-ai.test.ts +928 -448
- package/tests/gentle-design.test.ts +1313 -0
- package/tests/gentle-music.test.ts +452 -0
- package/tests/gentle-shell-bundled.test.ts +321 -248
- package/tests/gentle-shell-launcher.test.ts +29 -2
- package/tests/gentle-shell-startup-update.test.ts +77 -1
- package/tests/gentle-shell-uninstall.test.ts +59 -0
- package/tests/gentle-shell.test.ts +227 -27
- package/tests/gentle-todo.test.ts +11 -0
- package/tests/image-attachments.test.ts +119 -0
- package/tests/image-paste-chips.test.ts +236 -0
- package/tests/inprocess-reviewer.test.ts +55 -2
- package/tests/installer-bundles.test.ts +12 -6
- package/tests/installer-downloads.test.ts +32 -0
- package/tests/installer-posix-bootstrap.test.ts +8 -8
- package/tests/installer-preflight.test.ts +16 -0
- package/tests/installer-server.test.ts +2 -2
- package/tests/installer-windows-bootstrap.test.ts +61 -3
- package/tests/installer-windows-diagnostics.test.ts +111 -0
- package/tests/jobs-widget.test.ts +138 -0
- package/tests/main-channel-upgrade.test.ts +232 -2
- package/tests/main-channel.test.ts +140 -21
- package/tests/model-profiles-preservation.test.ts +2 -2
- package/tests/nan-provider.test.ts +36 -0
- package/tests/native-review-capability-contract.test.ts +11 -4
- package/tests/native-review-parity.test.ts +1 -1
- package/tests/notification-audio-native.test.ts +128 -9
- package/tests/notification-customize.test.ts +17 -5
- package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
- package/tests/orchestrator-budget.test.ts +5 -2
- package/tests/orchestrator-presence.test.ts +100 -2
- package/tests/orchestrator-rdd-ownership.test.ts +2 -2
- package/tests/orchestrator-scope.test.ts +14 -0
- package/tests/orchestrator-state.test.ts +29 -0
- package/tests/package-files.test.ts +34 -0
- package/tests/package-manifest.test.ts +6 -6
- package/tests/permission-mode-policy.test.ts +10 -1
- package/tests/pi-update.test.ts +104 -4
- package/tests/rdd-aware-verification-contract.test.ts +1 -1
- package/tests/review-host-relay-routing.test.ts +88 -1
- package/tests/review-host-relay.test.ts +9 -2
- package/tests/review-json-arguments.test.ts +20 -3
- package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
- package/tests/review-relay-transport-agent.test.ts +26 -0
- package/tests/review-reminder-receipt.test.ts +31 -1
- package/tests/runtime-harness.mjs +188 -211
- package/tests/session-changes.test.ts +2 -1
- package/tests/session-profile-append-controller-fork.test.ts +458 -0
- package/tests/session-profile-append-controller-pi.test.ts +290 -0
- package/tests/session-profile-append-controller.test.ts +731 -0
- package/tests/session-profile-disk-reader-pi.test.ts +183 -0
- package/tests/session-profile-disk-reader.test.ts +311 -0
- package/tests/session-profile-freeze.test.ts +6 -4
- package/tests/session-scratch.test.ts +68 -2
- package/tests/session-worktree-registry.test.ts +13 -1
- package/tests/shell-bar.test.ts +18 -6
- package/tests/shell-changes.test.ts +148 -1
- package/tests/shell-prompt.test.ts +3 -3
- package/tests/shell-render-cache.test.ts +134 -1
- package/tests/shell-sidebar-layout.test.ts +67 -0
- package/tests/short-check-routing-contract.test.ts +1 -1
- package/tests/skill-collision-prefixes.test.ts +3 -1
- package/tests/skill-registry-watcher.test.ts +17 -5
- package/tests/skill-registry.test.ts +47 -3
- package/tests/task-size-routing-contract.test.ts +1 -1
- package/tests/test-strength-contract.test.ts +1 -1
- package/tests/verifier-admission.test.ts +18 -1
- package/tests/vim-editor-adapter.test.ts +2 -2
- package/tests/voice-command.test.ts +773 -0
- package/tests/voice-install.test.ts +323 -0
- package/tests/voice-platform.test.ts +176 -0
- package/tests/voice-push-to-talk.test.ts +444 -0
- package/tests/voice-session.test.ts +434 -0
- package/tests/voice-settings.test.ts +49 -0
- package/tests/voice-ui.test.ts +97 -0
- package/tests/voice-worker.test.ts +445 -0
- package/tests/weak-model-economy-prompt.test.ts +32 -0
- package/tests/windows-hidden-processes.test.ts +25 -2
- package/tests/yolo-customize.test.ts +5 -1
- package/tests/yolo-mode.test.ts +1 -1
- package/tests/yolo-session-policy-capture.test.ts +104 -0
package/extensions/gentle-ai.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
1
2
|
import { appendSystemPromptOnce } from "../lib/append-system-prompt.ts";
|
|
2
3
|
import { dataArgumentRanges, recognizeDestructiveCommands } from "../lib/destructive-command-guard.ts";
|
|
3
4
|
import { defaultTempScratchFs, isTempScratchRemoval, type TempScratchFs } from "../lib/yolo-temp-scratch.ts";
|
|
4
5
|
import { SHELL_COMMAND_TOOLS } from "../lib/background-jobs.ts";
|
|
5
6
|
import { evaluateDeliveredTestTool } from "../lib/delivered-test-lint.ts";
|
|
6
7
|
import { commitSliceToolResult } from "../lib/commit-slice-signal.ts";
|
|
7
|
-
import { appendContextBackstopNotice, createContextBackstopSignal } from "../lib/context-backstop.ts";
|
|
8
|
+
import { appendContextBackstopNotice, createContextBackstopSignal, createThrottledContextMeasure } from "../lib/context-backstop.ts";
|
|
8
9
|
import { blockChildDestructiveCommand } from "./child-safety.ts";
|
|
9
10
|
import { allowedEditSurfaces as hasTaskScopedAllowedEditSurfaces, bindSessionRepositoryPreparation, captureBoundSessionRepositoryAuthority, isHomeOrBroadUserDirectory, prepareBoundSessionRepository, rejectUndocumentedWriterLaunch, rejectUnscopedBoundedWriterDispatch, safeBootstrapDirectory, sourcePathWithinProject } from "../lib/bounded-writer-admission.ts";
|
|
10
11
|
import { rejectUnriskedVerifierLaunch } from "../lib/verifier-admission.ts";
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
writeFile,
|
|
37
38
|
} from "node:fs/promises";
|
|
38
39
|
import { homedir } from "node:os";
|
|
40
|
+
import { discoverAgents, loadAgentsConfig, unmatchedProfileKeys } from "../lib/agents-config.ts";
|
|
39
41
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
40
42
|
import { fileURLToPath } from "node:url";
|
|
41
43
|
import type {
|
|
@@ -47,7 +49,7 @@ import type {
|
|
|
47
49
|
ToolResultEvent,
|
|
48
50
|
ToolResultEventResult,
|
|
49
51
|
} from "@earendil-works/pi-coding-agent";
|
|
50
|
-
import {
|
|
52
|
+
import { Text, isKeyRelease, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
|
|
51
53
|
import { resolveGentlePiAgentHome, gentlePiConfigHome } from "../lib/agent-home.ts";
|
|
52
54
|
import {
|
|
53
55
|
BACKGROUND_SUBAGENTS_FILE,
|
|
@@ -85,6 +87,7 @@ import {
|
|
|
85
87
|
PROFILE_ORCHESTRATOR_KEY,
|
|
86
88
|
profileExportPath,
|
|
87
89
|
profileRoutingRows,
|
|
90
|
+
unmatchedRoutingNotes,
|
|
88
91
|
profilesFilePath,
|
|
89
92
|
readProfileOrchestrator,
|
|
90
93
|
readProfilesFileResult,
|
|
@@ -119,8 +122,6 @@ import {
|
|
|
119
122
|
type OrchestratorSettingsReadResult,
|
|
120
123
|
parseOrchestratorModelRef,
|
|
121
124
|
} from "../lib/profiles-orchestrator.ts";
|
|
122
|
-
import { measureAgentsViewLayout, type AgentsViewLayout } from "../lib/agents-view-layout.ts";
|
|
123
|
-
import { NativeChoiceList } from "../lib/native-choice-list.ts";
|
|
124
125
|
import { createNativeFullscreenInteraction } from "../lib/native-fullscreen-interaction.ts";
|
|
125
126
|
import {
|
|
126
127
|
REVIEW_HOST_RELAY_FAILURE,
|
|
@@ -1209,7 +1210,7 @@ function pinRddStatusLine(pinned: string | undefined, next: string): string {
|
|
|
1209
1210
|
const RDD_OFF_REVIEW_SNIPPET = "Assess an authorized change and return its verification plan (read-only)";
|
|
1210
1211
|
const RDD_OFF_REVIEW_GUIDELINES: readonly string[] = [
|
|
1211
1212
|
'Receipt-driven development is off, so use gentle_review only for {"operation":"assess"} (read-only): after an authorized implementation, delegated or inline, call it over the diff and follow the returned plan; pass input {"escalate":{"item":1-6,"reason":"<one line>"}} only when the change touches a Task Size high-risk item, or {"baseRef":"<ref>","committedOnly":true} to assess a committed range.',
|
|
1212
|
-
"When ASSESS returns reviewDue or a native continuation, relay it unchanged
|
|
1213
|
+
"When ASSESS returns reviewDue or a native continuation, relay it unchanged but do not execute it while RDD is off; assessment metadata does not authorize STATUS, another review lifecycle operation, or enabling RDD. Continue the returned verification plan; tests, builds, and functional checks still run when applicable, and no assessment authorizes delivery.",
|
|
1213
1214
|
];
|
|
1214
1215
|
const RDD_OFF_REVIEW_DESCRIPTION = "Assess the current changes, or a committed range, and return the verification plan. Read-only; review outcomes never authorize delivery: commit, push, pull-request, and release commands follow ordinary repository policy.";
|
|
1215
1216
|
const RDD_OFF_HIDDEN_REVIEW_TOOLS: readonly string[] = ["gentle_review_scope", "gentle_review_capture_group", "gentle_review_capture"];
|
|
@@ -1467,7 +1468,7 @@ Harness principles:
|
|
|
1467
1468
|
- el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
|
|
1468
1469
|
- Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
|
|
1469
1470
|
- Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
|
|
1470
|
-
- For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation).
|
|
1471
|
+
- For behavior changes with applicable runnable deterministic tests and a clear expected outcome, use test-first by default: observe RED, GREEN, then refactor with focused checks. Write one RED test per requested rule. For every existing command or option the change touches, add one test proving its previous behavior still holds; add no other cases. An existing behavior counts as touched when it shares the code you changed (options, parsers, helpers, validation). Only when your diff changes shared code (options, parsers, helpers) that an existing command the request did not name also uses, run one differential check before finishing, as a single script: for each such command, run it with the new or changed input (for example a new option passed to a sibling subcommand) against the previous code (a copy of the base commit in a \`mktemp -d\` scratch directory, never committed as a test; committed tests never read git history or a fixed commit) and against your change, and compare stdout, stderr, and exit code; any difference nobody asked for is a regression to fix and pin with a test. Test presence alone does not establish applicability; no chat or TUI toggle activates it. For passive documentation, non-testable changes, an unavailable runner, or no meaningful RED, explain why and run proportionate ordinary functional or structural verification. Never invent lifecycle evidence or skip checks. Follow orchestrator-delegation.md for ODD forwarding and evidence.
|
|
1471
1472
|
- Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
|
|
1472
1473
|
- Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
|
|
1473
1474
|
|
|
@@ -1814,26 +1815,12 @@ const KEEP_CURRENT = "Keep current";
|
|
|
1814
1815
|
const INHERIT_MODEL = "Inherit active/default model";
|
|
1815
1816
|
const CUSTOM_MODEL = "Custom model id";
|
|
1816
1817
|
const INHERIT_THINKING = "Inherit effort";
|
|
1817
|
-
const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [
|
|
1818
|
-
INHERIT_THINKING,
|
|
1819
|
-
...THINKING_LEVELS,
|
|
1820
|
-
];
|
|
1821
1818
|
|
|
1822
1819
|
const MODEL_CONTROL_OPTIONS = [
|
|
1823
1820
|
KEEP_CURRENT,
|
|
1824
1821
|
INHERIT_MODEL,
|
|
1825
1822
|
CUSTOM_MODEL,
|
|
1826
1823
|
] as const;
|
|
1827
|
-
const MODEL_PANEL_MAX_RENDER_ROWS = 20;
|
|
1828
|
-
// Rows the agent list does not own: two borders, title, current-profile line,
|
|
1829
|
-
// blank, "Current assignments:", blank, both scroll indicators, blank, Continue,
|
|
1830
|
-
// Back, blank, and the two footer rows.
|
|
1831
|
-
const AGENT_LIST_CHROME_ROWS = 15;
|
|
1832
|
-
const AGENT_LIST_MAX_VISIBLE_ROWS = MODEL_PANEL_MAX_RENDER_ROWS - AGENT_LIST_CHROME_ROWS;
|
|
1833
|
-
// Rows the model list does not own: two borders, title, blank, search, blank,
|
|
1834
|
-
// blank, and the footer row.
|
|
1835
|
-
const MODEL_LIST_CHROME_ROWS = 8;
|
|
1836
|
-
const MODEL_LIST_MAX_VISIBLE_ROWS = 12;
|
|
1837
1824
|
|
|
1838
1825
|
function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
1839
1826
|
let current = value;
|
|
@@ -2070,6 +2057,21 @@ function yoloConfiguredRestriction(cwd: string, keys: readonly GuardedCommandKey
|
|
|
2070
2057
|
return restriction;
|
|
2071
2058
|
}
|
|
2072
2059
|
|
|
2060
|
+
/** Super Yolo ignores every configurable guardrail: only code-level denies survive. */
|
|
2061
|
+
const SUPER_YOLO_GUARDRAILS: RuntimeGuardrailsConfig = {
|
|
2062
|
+
autonomousMode: true,
|
|
2063
|
+
guardedCommands: { gitPush: "allow", gitRebase: "allow", gitBranchDeleteForce: "allow", npmPublish: "allow", piRemove: "allow" },
|
|
2064
|
+
};
|
|
2065
|
+
|
|
2066
|
+
/**
|
|
2067
|
+
* Super Yolo command decision: run everything without confirmation except the
|
|
2068
|
+
* code-level catastrophic denies (root/home recursive removal, --no-preserve-root,
|
|
2069
|
+
* hard-denied patterns). Configured guardrails are deliberately not consulted.
|
|
2070
|
+
*/
|
|
2071
|
+
function evaluateSuperYoloCommand(command: string): GuardAction {
|
|
2072
|
+
return evaluateGuardedCommand(command, SUPER_YOLO_GUARDRAILS).action === "block" ? "block" : "allow";
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2073
2075
|
async function confirmCommand(
|
|
2074
2076
|
command: string,
|
|
2075
2077
|
ctx: ExtensionContext,
|
|
@@ -2077,7 +2079,19 @@ async function confirmCommand(
|
|
|
2077
2079
|
herdrLifecycle: HerdrConfirmationLifecycle,
|
|
2078
2080
|
yoloActive = false,
|
|
2079
2081
|
autoGate?: () => Promise<ToolCallEventResult | undefined>,
|
|
2082
|
+
superActive = false,
|
|
2080
2083
|
): Promise<ToolCallEventResult | undefined> {
|
|
2084
|
+
if (superActive) {
|
|
2085
|
+
// Sensitive-path and delivered-test guards already ran before this call;
|
|
2086
|
+
// here only the code-level catastrophic denies remain.
|
|
2087
|
+
if (evaluateSuperYoloCommand(command) === "block") {
|
|
2088
|
+
return {
|
|
2089
|
+
block: true,
|
|
2090
|
+
reason: "Gentle AI safety policy blocked a catastrophic destructive shell command. Ask the user for an explicit safer plan.",
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
return autoGate?.();
|
|
2094
|
+
}
|
|
2081
2095
|
const guardrailsConfig = loadRuntimeGuardrailsConfig(ctx.cwd);
|
|
2082
2096
|
const evaluation = evaluateGuardedCommand(command, guardrailsConfig);
|
|
2083
2097
|
// Configuration provenance is distinct from legacy autonomy resolution, which
|
|
@@ -2515,7 +2529,7 @@ async function readGlobalEffectiveModelConfigFromAsync(
|
|
|
2515
2529
|
* discoverable agent it omits on inherit, not on whatever was materialized
|
|
2516
2530
|
* before. Padding the omitted agents with clear entries makes
|
|
2517
2531
|
* `applyModelConfig` remove their model profiles and frontmatter routing, the
|
|
2518
|
-
* same way
|
|
2532
|
+
* same way applying a profile with inherit entries clears an agent set.
|
|
2519
2533
|
*/
|
|
2520
2534
|
async function withOmittedAgentsClearedAsync(
|
|
2521
2535
|
cwd: string,
|
|
@@ -3085,31 +3099,80 @@ async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
|
|
|
3085
3099
|
return [...MODEL_CONTROL_OPTIONS, ...modelIds];
|
|
3086
3100
|
}
|
|
3087
3101
|
|
|
3102
|
+
/**
|
|
3103
|
+
* The effort levels each catalog model supports, keyed by its provider/model id.
|
|
3104
|
+
* Pi clamps an unsupported level at launch, so the models screen offers only
|
|
3105
|
+
* these and shows where a saved level would land. Models outside the catalog
|
|
3106
|
+
* are absent: their levels are unknown.
|
|
3107
|
+
*/
|
|
3108
|
+
async function getPiThinkingSupport(ctx: ExtensionContext): Promise<Record<string, ThinkingLevel[]>> {
|
|
3109
|
+
const support: Record<string, ThinkingLevel[]> = {};
|
|
3110
|
+
let raw: unknown;
|
|
3111
|
+
try {
|
|
3112
|
+
raw = await ctx.modelRegistry?.getAvailable();
|
|
3113
|
+
} catch {
|
|
3114
|
+
return support;
|
|
3115
|
+
}
|
|
3116
|
+
if (!Array.isArray(raw)) return support;
|
|
3117
|
+
for (const model of raw as Parameters<typeof getSupportedThinkingLevels>[0][]) {
|
|
3118
|
+
const id = normalizeModelId(`${model?.provider}/${model?.id}`);
|
|
3119
|
+
// Without the reasoning flag the catalog says nothing about effort: the
|
|
3120
|
+
// model stays unknown (every level offered) rather than "off only".
|
|
3121
|
+
if (!id || typeof model?.reasoning !== "boolean") continue;
|
|
3122
|
+
try {
|
|
3123
|
+
support[id] = getSupportedThinkingLevels(model).filter((level): level is ThinkingLevel =>
|
|
3124
|
+
(THINKING_LEVELS as readonly string[]).includes(level),
|
|
3125
|
+
);
|
|
3126
|
+
} catch {
|
|
3127
|
+
// A model whose metadata cannot be read stays unknown.
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return support;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
/**
|
|
3134
|
+
* Where Pi lands a requested level on a model, mirroring pi-ai's
|
|
3135
|
+
* clampThinkingLevel: the level itself when supported, else the nearest
|
|
3136
|
+
* supported level above it, else below it.
|
|
3137
|
+
*/
|
|
3138
|
+
function clampToSupportedLevel(level: ThinkingLevel, supported: readonly ThinkingLevel[]): ThinkingLevel {
|
|
3139
|
+
if (supported.includes(level)) return level;
|
|
3140
|
+
const start = THINKING_LEVELS.indexOf(level);
|
|
3141
|
+
for (let index = start + 1; index < THINKING_LEVELS.length; index += 1) {
|
|
3142
|
+
if (supported.includes(THINKING_LEVELS[index])) return THINKING_LEVELS[index];
|
|
3143
|
+
}
|
|
3144
|
+
for (let index = start - 1; index >= 0; index -= 1) {
|
|
3145
|
+
if (supported.includes(THINKING_LEVELS[index])) return THINKING_LEVELS[index];
|
|
3146
|
+
}
|
|
3147
|
+
return supported[0] ?? "off";
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3088
3150
|
interface OverlayComponent {
|
|
3089
3151
|
render(width: number): string[];
|
|
3090
3152
|
handleInput(data: string): void;
|
|
3091
3153
|
invalidate(): void;
|
|
3092
3154
|
}
|
|
3093
3155
|
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
| { type: "export"; config: AgentModelConfig }
|
|
3101
|
-
| { type: "restore"; config: AgentModelConfig }
|
|
3102
|
-
| { type: "cancel" };
|
|
3156
|
+
interface RoutingHitRegion {
|
|
3157
|
+
x0: number;
|
|
3158
|
+
x1: number;
|
|
3159
|
+
y: number;
|
|
3160
|
+
action: RoutingTableMouseAction;
|
|
3161
|
+
}
|
|
3103
3162
|
|
|
3104
|
-
const
|
|
3163
|
+
const LEAVE_ORCHESTRATOR_UNTOUCHED = "leave untouched";
|
|
3105
3164
|
|
|
3106
3165
|
const PANEL_TONE = {
|
|
3107
3166
|
BORDER: "border",
|
|
3108
3167
|
MUTED: "muted",
|
|
3168
|
+
DIM: "dim",
|
|
3109
3169
|
TEXT: "text",
|
|
3110
3170
|
TITLE: "title",
|
|
3111
3171
|
ACCENT: "accent",
|
|
3112
3172
|
STATUS: "status",
|
|
3173
|
+
EDITED: "edited",
|
|
3174
|
+
GOOD: "good",
|
|
3175
|
+
DANGER: "danger",
|
|
3113
3176
|
} as const;
|
|
3114
3177
|
|
|
3115
3178
|
type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE];
|
|
@@ -3117,535 +3180,1400 @@ type PanelTone = (typeof PANEL_TONE)[keyof typeof PANEL_TONE];
|
|
|
3117
3180
|
const PANEL_TONE_COLOR: Record<PanelTone, ThemeColor> = {
|
|
3118
3181
|
border: "border",
|
|
3119
3182
|
muted: "muted",
|
|
3183
|
+
dim: "dim",
|
|
3120
3184
|
text: "text",
|
|
3121
3185
|
title: "accent",
|
|
3122
3186
|
accent: "accent",
|
|
3123
3187
|
status: "thinkingHigh",
|
|
3188
|
+
edited: "warning",
|
|
3189
|
+
good: "success",
|
|
3190
|
+
danger: "error",
|
|
3124
3191
|
};
|
|
3125
3192
|
|
|
3126
|
-
|
|
3193
|
+
/** The pseudo row behind the Model ▾ / Effort ▾ header: it sets every agent at once. */
|
|
3194
|
+
const ROUTING_ALL_ROW = "__all";
|
|
3195
|
+
const ROUTING_CUSTOM_MODEL = "Custom model id…";
|
|
3196
|
+
/** The table's short form of a provider review role's inherited model and effort. */
|
|
3197
|
+
const ROUTING_PI_DEFAULT = "Pi default";
|
|
3198
|
+
/** Body lines around the agent rows: tabs, rail, description, blank, orchestrator, rule, header, blank, footer, hints, two feedback lines. */
|
|
3199
|
+
const ROUTING_TABLE_FIXED_ROWS = 12;
|
|
3200
|
+
/** Compact button labels for terminals too narrow for the full ones. */
|
|
3201
|
+
const ROUTING_TABLE_SHORT_BUTTONS: Partial<Record<RoutingTableButton, string>> = {
|
|
3202
|
+
use: "Use here",
|
|
3203
|
+
default: "Default",
|
|
3204
|
+
};
|
|
3205
|
+
/**
|
|
3206
|
+
* The key hints in display order, each with a keep priority (lower stays
|
|
3207
|
+
* longer): narrow terminals drop whole hints instead of cutting one in half.
|
|
3208
|
+
*/
|
|
3209
|
+
const ROUTING_TABLE_HINTS: Array<{ text: string; keep: number }> = [
|
|
3210
|
+
{ text: "←→ profile", keep: 5 },
|
|
3211
|
+
{ text: "↑↓ agent", keep: 6 },
|
|
3212
|
+
{ text: "enter model", keep: 1 },
|
|
3213
|
+
{ text: "e effort", keep: 2 },
|
|
3214
|
+
{ text: "s save", keep: 3 },
|
|
3215
|
+
{ text: "u use", keep: 7 },
|
|
3216
|
+
{ text: "d default", keep: 8 },
|
|
3217
|
+
{ text: "n new", keep: 10 },
|
|
3218
|
+
{ text: "m menu", keep: 9 },
|
|
3219
|
+
{ text: "esc close", keep: 4 },
|
|
3220
|
+
];
|
|
3221
|
+
/** Model card chrome (title, search, rule, blank, hint), its borders, and a margin so the card never touches the frame. */
|
|
3222
|
+
const ROUTING_MODEL_CARD_CHROME_ROWS = 11;
|
|
3223
|
+
|
|
3224
|
+
type RoutingTableAction =
|
|
3225
|
+
| { type: "load"; name: string }
|
|
3226
|
+
| { type: "custom"; target: string; config: AgentModelConfig }
|
|
3227
|
+
| { type: "use-session"; name: string; config?: AgentModelConfig }
|
|
3228
|
+
| { type: "apply-global"; name: string; config: AgentModelConfig }
|
|
3229
|
+
| { type: "save"; name: string; config: AgentModelConfig }
|
|
3230
|
+
| { type: "create" }
|
|
3231
|
+
| { type: "create-live" }
|
|
3232
|
+
| { type: "duplicate"; name: string }
|
|
3233
|
+
| { type: "rename"; name: string }
|
|
3234
|
+
| { type: "delete"; name: string }
|
|
3235
|
+
| { type: "export-profile"; name: string }
|
|
3236
|
+
| { type: "import-profile" }
|
|
3237
|
+
| { type: "pin"; source: ProfilePinSource; name: string }
|
|
3238
|
+
| { type: "close" };
|
|
3239
|
+
|
|
3240
|
+
/**
|
|
3241
|
+
* `saveFirst` asks the loop to write the draft into the shown profile before
|
|
3242
|
+
* running the action (the "Save and switch" answer). `draft` carries unsaved
|
|
3243
|
+
* edits across actions that keep the same profile on screen (rename, pin, a
|
|
3244
|
+
* declined delete), so managing a profile never throws away what you typed.
|
|
3245
|
+
*/
|
|
3246
|
+
type RoutingTableResult = RoutingTableAction & { saveFirst?: AgentModelConfig; draft?: AgentModelConfig };
|
|
3247
|
+
|
|
3248
|
+
type RoutingTableButton = "use" | "default" | "save" | "switch-save" | "switch-discard" | "switch-stay";
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* What the screen needs to show another profile without closing: the store
|
|
3252
|
+
* after an optional save, fresh scope state, and the footer line. `error`
|
|
3253
|
+
* means the save failed and the shown profile stays, edits intact.
|
|
3254
|
+
*/
|
|
3255
|
+
type RoutingProfileSwitch =
|
|
3256
|
+
| { file: AgentProfilesFile; sessionBoundName?: string; pinWinner?: string; notices: string[]; feedback: string }
|
|
3257
|
+
| { error: string };
|
|
3258
|
+
|
|
3259
|
+
/**
|
|
3260
|
+
* Switches the shown profile in place. Closing the overlay and reopening it on
|
|
3261
|
+
* the next tab let the terminal paint the screen beneath for a frame (and wait
|
|
3262
|
+
* on the async model catalog before the new frame), which is the tab flicker.
|
|
3263
|
+
*/
|
|
3264
|
+
type RoutingProfileSwitcher = (name: string, saveFirst?: AgentModelConfig) => RoutingProfileSwitch;
|
|
3265
|
+
|
|
3266
|
+
type RoutingTableMouseAction =
|
|
3267
|
+
| { kind: "tab"; name: string }
|
|
3268
|
+
| { kind: "tab-menu" }
|
|
3269
|
+
| { kind: "new" }
|
|
3270
|
+
| { kind: "row"; row: string }
|
|
3271
|
+
| { kind: "effort"; row: string }
|
|
3272
|
+
| { kind: "model-option"; index: number }
|
|
3273
|
+
| { kind: "effort-option"; index: number }
|
|
3274
|
+
| { kind: "menu-item"; id: string }
|
|
3275
|
+
| { kind: "button"; button: RoutingTableButton };
|
|
3276
|
+
|
|
3277
|
+
interface RoutingMenuItem {
|
|
3278
|
+
id: string;
|
|
3279
|
+
label: string;
|
|
3280
|
+
tone?: PanelTone;
|
|
3281
|
+
}
|
|
3282
|
+
|
|
3283
|
+
const ROUTING_TABLE_BUTTONS: Record<RoutingTableButton, string> = {
|
|
3284
|
+
use: "Use in this session",
|
|
3285
|
+
default: "Make default",
|
|
3286
|
+
save: "Save",
|
|
3287
|
+
"switch-save": "Save and switch",
|
|
3288
|
+
"switch-discard": "Discard",
|
|
3289
|
+
"switch-stay": "Stay",
|
|
3290
|
+
};
|
|
3291
|
+
|
|
3292
|
+
/**
|
|
3293
|
+
* `/gentle:profiles` — one screen, one table, profiles as
|
|
3294
|
+
* tabs. Each tab is a profile and carries where it is in effect (◉ this
|
|
3295
|
+
* session, ★ default for new sessions, ⌂ this repo); the table below is that
|
|
3296
|
+
* profile's routing, orchestrator first; three buttons decide where it applies.
|
|
3297
|
+
* Model and effort changes happen in small cards over the still-visible table.
|
|
3298
|
+
* Keyboard and pointer reach the same actions, and each key means one thing.
|
|
3299
|
+
*/
|
|
3300
|
+
class RoutingTablePanel implements OverlayComponent {
|
|
3301
|
+
private overlay: "model" | "effort" | "menu" | "new" | "switch" | undefined;
|
|
3302
|
+
private target: string = PROFILE_ORCHESTRATOR_KEY;
|
|
3303
|
+
private pending: RoutingTableResult | undefined;
|
|
3127
3304
|
private cursor = 0;
|
|
3128
|
-
private
|
|
3129
|
-
private selectedRow = SET_ALL_AGENTS;
|
|
3305
|
+
private query = "";
|
|
3130
3306
|
private modelCursor = 0;
|
|
3131
3307
|
private effortCursor = 0;
|
|
3132
|
-
private
|
|
3133
|
-
private
|
|
3308
|
+
private menuCursor = 0;
|
|
3309
|
+
private hovered: string | undefined;
|
|
3310
|
+
private escArmed = false;
|
|
3311
|
+
private feedback: string | undefined;
|
|
3312
|
+
private completed = false;
|
|
3313
|
+
private hitRegions: RoutingHitRegion[] = [];
|
|
3314
|
+
private activeTabColumn = 0;
|
|
3315
|
+
private newColumn = 0;
|
|
3316
|
+
private draft: AgentModelConfig;
|
|
3317
|
+
private stored: AgentModelConfig;
|
|
3134
3318
|
private readonly rows: string[];
|
|
3135
|
-
private readonly
|
|
3136
|
-
private readonly
|
|
3319
|
+
private readonly agents: string[];
|
|
3320
|
+
private readonly catalog: string[];
|
|
3321
|
+
private readonly done: (result: RoutingTableResult) => void;
|
|
3137
3322
|
private readonly theme: Theme | undefined;
|
|
3138
|
-
// The profile `u` writes to, named up front so the key never targets a surprise.
|
|
3139
|
-
private readonly profileLabel: string;
|
|
3140
|
-
// Terminal rows, so the card fills the fullscreen overlay like `/gentle:profiles`.
|
|
3141
3323
|
private readonly terminalRows: (() => number) | undefined;
|
|
3324
|
+
private profileName: string;
|
|
3325
|
+
private file: AgentProfilesFile;
|
|
3326
|
+
private sessionBoundName: string | undefined;
|
|
3327
|
+
private pinWinner: string | undefined;
|
|
3328
|
+
private readonly thinkingSupport: Record<string, readonly ThinkingLevel[]>;
|
|
3329
|
+
/** Configuration problems the screen must not hide: pins that do not resolve, keys no agent matches. */
|
|
3330
|
+
private notices: string[];
|
|
3331
|
+
private readonly switchProfile: RoutingProfileSwitcher | undefined;
|
|
3142
3332
|
|
|
3143
3333
|
constructor(
|
|
3144
|
-
|
|
3334
|
+
draft: AgentModelConfig,
|
|
3145
3335
|
modelOptions: string[],
|
|
3146
3336
|
agents: string[],
|
|
3147
|
-
done: (result:
|
|
3337
|
+
done: (result: RoutingTableResult) => void,
|
|
3148
3338
|
theme?: Theme,
|
|
3149
|
-
|
|
3339
|
+
profileName = "current",
|
|
3150
3340
|
terminalRows?: () => number,
|
|
3341
|
+
options: {
|
|
3342
|
+
file?: AgentProfilesFile;
|
|
3343
|
+
sessionBoundName?: string;
|
|
3344
|
+
pinWinner?: string;
|
|
3345
|
+
feedback?: string;
|
|
3346
|
+
thinkingSupport?: Record<string, readonly ThinkingLevel[]>;
|
|
3347
|
+
notices?: string[];
|
|
3348
|
+
switchProfile?: RoutingProfileSwitcher;
|
|
3349
|
+
} = {},
|
|
3151
3350
|
) {
|
|
3152
|
-
this.
|
|
3153
|
-
this.
|
|
3154
|
-
this.
|
|
3351
|
+
this.switchProfile = options.switchProfile;
|
|
3352
|
+
this.thinkingSupport = options.thinkingSupport ?? {};
|
|
3353
|
+
this.notices = options.notices ?? [];
|
|
3354
|
+
this.file = options.file ?? bootstrapProfilesFile(cloneModelConfig(draft));
|
|
3355
|
+
const names = Object.keys(this.file.profiles);
|
|
3356
|
+
this.profileName = hasOwnProfile(this.file.profiles, profileName) ? profileName : (names[0] ?? profileName);
|
|
3357
|
+
this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {});
|
|
3358
|
+
this.draft = cloneModelConfig(draft);
|
|
3359
|
+
this.agents = [...agents];
|
|
3360
|
+
this.rows = [PROFILE_ORCHESTRATOR_KEY, ROUTING_ALL_ROW, ...agents];
|
|
3361
|
+
// The control entries of the old panel (keep current, custom id) are not
|
|
3362
|
+
// models; this screen offers inherit and custom as its own options.
|
|
3363
|
+
this.catalog = modelOptions.filter((option) => !(MODEL_CONTROL_OPTIONS as readonly string[]).includes(option));
|
|
3155
3364
|
this.done = done;
|
|
3156
3365
|
this.theme = theme;
|
|
3157
|
-
this.profileLabel = profileLabel;
|
|
3158
3366
|
this.terminalRows = terminalRows;
|
|
3367
|
+
this.sessionBoundName = options.sessionBoundName;
|
|
3368
|
+
this.pinWinner = options.pinWinner;
|
|
3369
|
+
this.feedback = options.feedback;
|
|
3159
3370
|
}
|
|
3160
3371
|
|
|
3161
|
-
invalidate(): void {
|
|
3372
|
+
invalidate(): void {
|
|
3373
|
+
this.hovered = undefined;
|
|
3374
|
+
}
|
|
3162
3375
|
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
}
|
|
3168
|
-
if (this.mode === "effort") {
|
|
3169
|
-
this.handleEffortInput(data);
|
|
3170
|
-
return;
|
|
3171
|
-
}
|
|
3172
|
-
this.handleAgentInput(data);
|
|
3376
|
+
// -- state ----------------------------------------------------------------
|
|
3377
|
+
|
|
3378
|
+
private profileNames(): string[] {
|
|
3379
|
+
return Object.keys(this.file.profiles).filter((name) => hasOwnProfile(this.file.profiles, name));
|
|
3173
3380
|
}
|
|
3174
3381
|
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
const
|
|
3178
|
-
this.
|
|
3179
|
-
|
|
3180
|
-
: this.mode === "effort"
|
|
3181
|
-
? this.renderEffortPicker(innerWidth)
|
|
3182
|
-
: this.renderAgentList(innerWidth);
|
|
3183
|
-
return this.renderCard(lines, width);
|
|
3184
|
-
}
|
|
3185
|
-
|
|
3186
|
-
// In the fullscreen overlay a list owns every row its chrome leaves free;
|
|
3187
|
-
// without a terminal height it keeps the fixed window.
|
|
3188
|
-
private visibleListRows(fixedRows: number, chromeRows: number): number {
|
|
3189
|
-
if (!this.terminalRows) return fixedRows;
|
|
3190
|
-
return Math.max(1, Math.floor(this.terminalRows()) - chromeRows);
|
|
3191
|
-
}
|
|
3192
|
-
|
|
3193
|
-
private renderCard(body: string[], width: number): string[] {
|
|
3194
|
-
let lines = body;
|
|
3195
|
-
const innerWidth = Math.max(1, width - 4);
|
|
3196
|
-
const horizontal = "─".repeat(innerWidth + 2);
|
|
3197
|
-
const border = (text: string) => this.renderText(text, "border");
|
|
3198
|
-
const bodyRows = this.terminalRows ? Math.floor(this.terminalRows()) - 2 : 0;
|
|
3199
|
-
if (lines.length < bodyRows) {
|
|
3200
|
-
lines = [...lines, ...Array<string>(bodyRows - lines.length).fill("")];
|
|
3382
|
+
private changeCount(): number {
|
|
3383
|
+
let count = 0;
|
|
3384
|
+
for (const key of [PROFILE_ORCHESTRATOR_KEY, ...this.agents]) {
|
|
3385
|
+
if (this.isEdited(key, "model")) count += 1;
|
|
3386
|
+
if (this.isEdited(key, "thinking")) count += 1;
|
|
3201
3387
|
}
|
|
3202
|
-
return
|
|
3203
|
-
border(`╭${horizontal}╮`),
|
|
3204
|
-
...lines.map(
|
|
3205
|
-
(line) =>
|
|
3206
|
-
`${border("│")} ${this.fitStyledLine(line, innerWidth)} ${border("│")}`,
|
|
3207
|
-
),
|
|
3208
|
-
border(`╰${horizontal}╯`),
|
|
3209
|
-
];
|
|
3388
|
+
return count;
|
|
3210
3389
|
}
|
|
3211
3390
|
|
|
3212
|
-
private
|
|
3213
|
-
|
|
3214
|
-
if (visible.length > width) {
|
|
3215
|
-
return truncateToWidth(visible, Math.max(1, width), "…", true);
|
|
3216
|
-
}
|
|
3217
|
-
return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
|
|
3391
|
+
private isEdited(key: string, field: "model" | "thinking"): boolean {
|
|
3392
|
+
return (this.draft[key]?.[field] ?? undefined) !== (this.stored[key]?.[field] ?? undefined);
|
|
3218
3393
|
}
|
|
3219
3394
|
|
|
3220
|
-
private
|
|
3221
|
-
const
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
);
|
|
3227
|
-
return tone ? this.renderText(safe, tone) : safe;
|
|
3395
|
+
private scopeGlyphs(name: string): Array<{ text: string; tone: PanelTone }> {
|
|
3396
|
+
const glyphs: Array<{ text: string; tone: PanelTone }> = [];
|
|
3397
|
+
if (this.sessionBoundName === name) glyphs.push({ text: "◉", tone: "good" });
|
|
3398
|
+
if (this.file.active === name) glyphs.push({ text: "★", tone: "accent" });
|
|
3399
|
+
if (this.pinWinner === name) glyphs.push({ text: "⌂", tone: "edited" });
|
|
3400
|
+
return glyphs;
|
|
3228
3401
|
}
|
|
3229
3402
|
|
|
3230
|
-
private
|
|
3231
|
-
const
|
|
3232
|
-
if (
|
|
3233
|
-
|
|
3403
|
+
private scopeSentence(name: string): string {
|
|
3404
|
+
const parts: string[] = [];
|
|
3405
|
+
if (this.sessionBoundName === name) parts.push("Used in this session.");
|
|
3406
|
+
if (this.file.active === name) parts.push("Default for new sessions.");
|
|
3407
|
+
if (this.pinWinner === name) parts.push("Used in this repo.");
|
|
3408
|
+
return parts.length > 0 ? parts.join(" ") : "Not in use. Pick a button below to use it.";
|
|
3234
3409
|
}
|
|
3235
3410
|
|
|
3236
|
-
private
|
|
3237
|
-
|
|
3411
|
+
private rowLabel(key: string): string {
|
|
3412
|
+
if (key === PROFILE_ORCHESTRATOR_KEY) return "the orchestrator";
|
|
3413
|
+
if (key === ROUTING_ALL_ROW) return "every agent";
|
|
3414
|
+
return key;
|
|
3238
3415
|
}
|
|
3239
3416
|
|
|
3240
|
-
private
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3417
|
+
private modelValue(key: string): string {
|
|
3418
|
+
if (key === PROFILE_ORCHESTRATOR_KEY) return this.draft[key]?.model ?? LEAVE_ORCHESTRATOR_UNTOUCHED;
|
|
3419
|
+
// Provider review roles inherit Pi's persisted default; the table cell says
|
|
3420
|
+
// so in short, the cards spell it out.
|
|
3421
|
+
if (isProviderReviewRole(key)) return this.draft[key]?.model ?? ROUTING_PI_DEFAULT;
|
|
3422
|
+
return this.draft[key]?.model ?? routingDefaultLabel(key, "model");
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
private effortValue(key: string): string {
|
|
3426
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) return "";
|
|
3427
|
+
const thinking = this.draft[key]?.thinking;
|
|
3428
|
+
if (thinking === undefined) return isProviderReviewRole(key) ? ROUTING_PI_DEFAULT : routingDefaultLabel(key, "effort");
|
|
3429
|
+
const supported = this.supportedLevels(key);
|
|
3430
|
+
const landed = supported ? clampToSupportedLevel(thinking, supported) : thinking;
|
|
3431
|
+
return landed === thinking ? thinking : `${thinking} → ${landed}`;
|
|
3432
|
+
}
|
|
3433
|
+
|
|
3434
|
+
/**
|
|
3435
|
+
* The levels the row's model supports, from the catalog. Every agent at once
|
|
3436
|
+
* offers what all their known models share. Undefined means unknown: the row
|
|
3437
|
+
* inherits its model, or the model is not in the catalog.
|
|
3438
|
+
*/
|
|
3439
|
+
private supportedLevels(key: string): readonly ThinkingLevel[] | undefined {
|
|
3440
|
+
if (key !== ROUTING_ALL_ROW) {
|
|
3441
|
+
const model = this.draft[key]?.model;
|
|
3442
|
+
return model === undefined ? undefined : this.thinkingSupport[model];
|
|
3257
3443
|
}
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3444
|
+
const known = this.agents
|
|
3445
|
+
.map((agent) => this.draft[agent]?.model)
|
|
3446
|
+
.map((model) => (model === undefined ? undefined : this.thinkingSupport[model]))
|
|
3447
|
+
.filter((levels): levels is readonly ThinkingLevel[] => levels !== undefined);
|
|
3448
|
+
if (known.length === 0) return undefined;
|
|
3449
|
+
return THINKING_LEVELS.filter((level) => known.every((levels) => levels.includes(level)));
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
/** The effort card's choices: inherit, then the supported levels, or every level when unknown. */
|
|
3453
|
+
private effortOptions(): Array<ThinkingLevel | typeof INHERIT_THINKING> {
|
|
3454
|
+
const supported = this.supportedLevels(this.target);
|
|
3455
|
+
return [INHERIT_THINKING, ...THINKING_LEVELS.filter((level) => !supported || supported.includes(level))];
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
/** The value the picker marks as current: one agent's, or every agent's when they agree. */
|
|
3459
|
+
private currentValue(key: string, field: "model" | "thinking"): string | undefined {
|
|
3460
|
+
if (key === ROUTING_ALL_ROW) {
|
|
3461
|
+
const values = this.agents.map((agent) => this.draft[agent]?.[field]);
|
|
3462
|
+
return values.length > 0 && values.every((value) => value === values[0]) ? values[0] : undefined;
|
|
3261
3463
|
}
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3464
|
+
return this.draft[key]?.[field];
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
private inheritLabel(key: string): string {
|
|
3468
|
+
if (key === PROFILE_ORCHESTRATOR_KEY) return LEAVE_ORCHESTRATOR_UNTOUCHED;
|
|
3469
|
+
if (key !== ROUTING_ALL_ROW && isProviderReviewRole(key)) return routingDefaultLabel(key, "model");
|
|
3470
|
+
return "inherit";
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
private modelOptions(): string[] {
|
|
3474
|
+
const query = this.query.trim().toLowerCase();
|
|
3475
|
+
const all = [this.inheritLabel(this.target), ...this.catalog];
|
|
3476
|
+
const matches = query ? all.filter((option) => option.toLowerCase().includes(query)) : all;
|
|
3477
|
+
return [...matches, ROUTING_CUSTOM_MODEL];
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
private menuItems(): RoutingMenuItem[] {
|
|
3481
|
+
if (this.overlay === "new") {
|
|
3482
|
+
return [
|
|
3483
|
+
{ id: "new-copy", label: `Copy of ${this.profileName}` },
|
|
3484
|
+
{ id: "new-live", label: "From what's running now" },
|
|
3485
|
+
{ id: "new-empty", label: "Empty", tone: "muted" },
|
|
3486
|
+
];
|
|
3265
3487
|
}
|
|
3266
|
-
|
|
3267
|
-
|
|
3488
|
+
return [
|
|
3489
|
+
{ id: "rename", label: "Rename" },
|
|
3490
|
+
{ id: "duplicate", label: "Duplicate" },
|
|
3491
|
+
{ id: "repo", label: this.pinWinner === this.profileName ? "Stop using in this repo" : "Use in this repo" },
|
|
3492
|
+
{ id: "delete", label: "Delete", tone: "danger" },
|
|
3493
|
+
];
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// -- actions ----------------------------------------------------------------
|
|
3497
|
+
|
|
3498
|
+
private finish(result: RoutingTableResult): void {
|
|
3499
|
+
if (this.completed) return;
|
|
3500
|
+
if (result.type === "load" && this.switchProfile) {
|
|
3501
|
+
this.showProfile(result.name, result.saveFirst);
|
|
3268
3502
|
return;
|
|
3269
3503
|
}
|
|
3270
|
-
|
|
3271
|
-
|
|
3504
|
+
this.completed = true;
|
|
3505
|
+
this.done(result);
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3508
|
+
/** Replaces the shown profile on this same screen, as a fresh open on that tab would. */
|
|
3509
|
+
private showProfile(name: string, saveFirst?: AgentModelConfig): void {
|
|
3510
|
+
const next = this.switchProfile!(name, saveFirst);
|
|
3511
|
+
this.closeOverlay();
|
|
3512
|
+
if ("error" in next) {
|
|
3513
|
+
this.say(next.error);
|
|
3272
3514
|
return;
|
|
3273
3515
|
}
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3516
|
+
this.file = next.file;
|
|
3517
|
+
this.profileName = hasOwnProfile(this.file.profiles, name) ? name : (this.profileNames()[0] ?? name);
|
|
3518
|
+
this.stored = cloneModelConfig(normalizeModelConfig(this.file.profiles[this.profileName]) ?? {});
|
|
3519
|
+
this.draft = cloneModelConfig(this.stored);
|
|
3520
|
+
this.sessionBoundName = next.sessionBoundName;
|
|
3521
|
+
this.pinWinner = next.pinWinner;
|
|
3522
|
+
this.notices = next.notices;
|
|
3523
|
+
this.target = PROFILE_ORCHESTRATOR_KEY;
|
|
3524
|
+
this.cursor = 0;
|
|
3525
|
+
this.hovered = undefined;
|
|
3526
|
+
this.feedback = next.feedback;
|
|
3527
|
+
this.escArmed = false;
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
private say(message: string): void {
|
|
3531
|
+
this.feedback = message;
|
|
3532
|
+
this.escArmed = false;
|
|
3533
|
+
}
|
|
3534
|
+
|
|
3535
|
+
private closeOverlay(): void {
|
|
3536
|
+
this.overlay = undefined;
|
|
3537
|
+
this.pending = undefined;
|
|
3538
|
+
this.query = "";
|
|
3539
|
+
}
|
|
3540
|
+
|
|
3541
|
+
/** Leaving this profile with unsaved edits asks first; nothing is lost in silence. */
|
|
3542
|
+
private leave(result: RoutingTableResult): void {
|
|
3543
|
+
if (this.changeCount() === 0) {
|
|
3544
|
+
this.finish(result);
|
|
3278
3545
|
return;
|
|
3279
3546
|
}
|
|
3280
|
-
|
|
3281
|
-
|
|
3547
|
+
this.pending = result;
|
|
3548
|
+
this.overlay = "switch";
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
private switchTab(name: string): void {
|
|
3552
|
+
if (name === this.profileName) return;
|
|
3553
|
+
this.leave({ type: "load", name });
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
private switchBy(delta: number): void {
|
|
3557
|
+
const names = this.profileNames();
|
|
3558
|
+
const index = names.indexOf(this.profileName);
|
|
3559
|
+
const next = names[Math.max(0, Math.min(names.length - 1, index + delta))];
|
|
3560
|
+
if (next) this.switchTab(next);
|
|
3561
|
+
}
|
|
3562
|
+
|
|
3563
|
+
private openModel(key: string): void {
|
|
3564
|
+
this.target = key;
|
|
3565
|
+
this.overlay = "model";
|
|
3566
|
+
this.query = "";
|
|
3567
|
+
const options = this.modelOptions();
|
|
3568
|
+
const current = this.currentValue(key, "model") ?? (key === ROUTING_ALL_ROW ? undefined : this.inheritLabel(key));
|
|
3569
|
+
this.modelCursor = Math.max(0, current === undefined ? 0 : options.indexOf(current));
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
private openEffort(key: string): void {
|
|
3573
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) {
|
|
3574
|
+
this.say("Pick a model for the orchestrator first; its effort follows the model.");
|
|
3282
3575
|
return;
|
|
3283
3576
|
}
|
|
3284
|
-
|
|
3285
|
-
|
|
3577
|
+
this.target = key;
|
|
3578
|
+
this.overlay = "effort";
|
|
3579
|
+
const current = this.currentValue(key, "thinking") as ThinkingLevel | undefined;
|
|
3580
|
+
const supported = this.supportedLevels(key);
|
|
3581
|
+
const landed = current !== undefined && supported ? clampToSupportedLevel(current, supported) : current;
|
|
3582
|
+
this.effortCursor = Math.max(0, landed === undefined ? 0 : this.effortOptions().indexOf(landed));
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
private openMenu(kind: "menu" | "new"): void {
|
|
3586
|
+
this.overlay = kind;
|
|
3587
|
+
this.menuCursor = 0;
|
|
3588
|
+
}
|
|
3589
|
+
|
|
3590
|
+
private pickModel(option: string | undefined): void {
|
|
3591
|
+
if (option === undefined) return;
|
|
3592
|
+
if (option === ROUTING_CUSTOM_MODEL) {
|
|
3593
|
+
this.finish({ type: "custom", target: this.target, config: cloneModelConfig(this.draft) });
|
|
3286
3594
|
return;
|
|
3287
3595
|
}
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3596
|
+
const model = option === this.inheritLabel(this.target) ? undefined : option;
|
|
3597
|
+
for (const key of this.targetKeys()) this.setModel(key, model);
|
|
3598
|
+
this.say(`${this.rowLabel(this.target)}: model ${model ?? this.inheritLabel(this.target)}. Save keeps it in ${this.profileName}.`);
|
|
3599
|
+
this.closeOverlay();
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
private pickEffort(index: number): void {
|
|
3603
|
+
const option = this.effortOptions()[index];
|
|
3604
|
+
if (option === undefined) return;
|
|
3605
|
+
const thinking = option === INHERIT_THINKING ? undefined : (option as ThinkingLevel);
|
|
3606
|
+
for (const key of this.targetKeys()) this.setThinking(key, thinking);
|
|
3607
|
+
this.say(`${this.rowLabel(this.target)}: effort ${thinking ?? "inherit"}. Save keeps it in ${this.profileName}.`);
|
|
3608
|
+
this.closeOverlay();
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
private targetKeys(): string[] {
|
|
3612
|
+
return this.target === ROUTING_ALL_ROW ? this.agents : [this.target];
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
private setModel(key: string, model: string | undefined): void {
|
|
3616
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && model === undefined) {
|
|
3617
|
+
delete this.draft[key];
|
|
3294
3618
|
return;
|
|
3295
3619
|
}
|
|
3296
|
-
|
|
3297
|
-
if (
|
|
3298
|
-
|
|
3299
|
-
|
|
3620
|
+
const entry = { ...(this.draft[key] ?? {}) };
|
|
3621
|
+
if (model === undefined) delete entry.model;
|
|
3622
|
+
else entry.model = model;
|
|
3623
|
+
this.draft[key] = entry.model || entry.thinking ? entry : {};
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
private setThinking(key: string, thinking: ThinkingLevel | undefined): void {
|
|
3627
|
+
if (key === PROFILE_ORCHESTRATOR_KEY && !this.draft[key]?.model) return;
|
|
3628
|
+
const entry = { ...(this.draft[key] ?? {}) };
|
|
3629
|
+
if (thinking === undefined) delete entry.thinking;
|
|
3630
|
+
else entry.thinking = thinking;
|
|
3631
|
+
this.draft[key] = entry.model || entry.thinking ? entry : {};
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
private runMenuItem(id: string): void {
|
|
3635
|
+
const name = this.profileName;
|
|
3636
|
+
const draft = cloneModelConfig(this.draft);
|
|
3637
|
+
switch (id) {
|
|
3638
|
+
case "rename":
|
|
3639
|
+
this.finish({ type: "rename", name, draft });
|
|
3640
|
+
return;
|
|
3641
|
+
case "duplicate":
|
|
3642
|
+
case "new-copy":
|
|
3643
|
+
this.closeOverlay();
|
|
3644
|
+
this.leave({ type: "duplicate", name });
|
|
3645
|
+
return;
|
|
3646
|
+
case "repo":
|
|
3647
|
+
this.finish({ type: "pin", source: "local", name, draft });
|
|
3648
|
+
return;
|
|
3649
|
+
case "delete":
|
|
3650
|
+
this.finish({ type: "delete", name, draft });
|
|
3651
|
+
return;
|
|
3652
|
+
case "new-live":
|
|
3653
|
+
this.closeOverlay();
|
|
3654
|
+
this.leave({ type: "create-live" });
|
|
3655
|
+
return;
|
|
3656
|
+
case "new-empty":
|
|
3657
|
+
this.closeOverlay();
|
|
3658
|
+
this.leave({ type: "create" });
|
|
3659
|
+
return;
|
|
3300
3660
|
}
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3661
|
+
}
|
|
3662
|
+
|
|
3663
|
+
private pressButton(button: RoutingTableButton): void {
|
|
3664
|
+
const config = cloneModelConfig(this.draft);
|
|
3665
|
+
switch (button) {
|
|
3666
|
+
case "use":
|
|
3667
|
+
this.finish({ type: "use-session", name: this.profileName, config });
|
|
3668
|
+
return;
|
|
3669
|
+
case "default":
|
|
3670
|
+
this.finish({ type: "apply-global", name: this.profileName, config });
|
|
3671
|
+
return;
|
|
3672
|
+
case "save":
|
|
3673
|
+
if (this.changeCount() === 0) {
|
|
3674
|
+
this.say("Nothing to save.");
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
this.finish({ type: "save", name: this.profileName, config });
|
|
3678
|
+
return;
|
|
3679
|
+
case "switch-save":
|
|
3680
|
+
if (this.pending) this.finish({ ...this.pending, saveFirst: config });
|
|
3681
|
+
return;
|
|
3682
|
+
case "switch-discard":
|
|
3683
|
+
if (this.pending) this.finish(this.pending);
|
|
3684
|
+
return;
|
|
3685
|
+
case "switch-stay":
|
|
3686
|
+
this.closeOverlay();
|
|
3687
|
+
return;
|
|
3304
3688
|
}
|
|
3305
|
-
this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
|
|
3306
|
-
this.mode = "models";
|
|
3307
|
-
this.modelCursor = 0;
|
|
3308
|
-
this.query = "";
|
|
3309
3689
|
}
|
|
3310
3690
|
|
|
3311
|
-
|
|
3312
|
-
|
|
3691
|
+
// -- keyboard ----------------------------------------------------------------
|
|
3692
|
+
|
|
3693
|
+
handleInput(data: string): void {
|
|
3694
|
+
if (isKeyRelease(data)) return;
|
|
3313
3695
|
if (matchesKey(data, "ctrl+c")) {
|
|
3314
|
-
this.
|
|
3696
|
+
this.finish({ type: "close" });
|
|
3315
3697
|
return;
|
|
3316
3698
|
}
|
|
3699
|
+
switch (this.overlay) {
|
|
3700
|
+
case "model":
|
|
3701
|
+
this.handleModelKey(data);
|
|
3702
|
+
return;
|
|
3703
|
+
case "effort":
|
|
3704
|
+
this.handleEffortKey(data);
|
|
3705
|
+
return;
|
|
3706
|
+
case "menu":
|
|
3707
|
+
case "new":
|
|
3708
|
+
this.handleMenuKey(data);
|
|
3709
|
+
return;
|
|
3710
|
+
case "switch":
|
|
3711
|
+
this.handleSwitchKey(data);
|
|
3712
|
+
return;
|
|
3713
|
+
default:
|
|
3714
|
+
this.handleTableKey(data);
|
|
3715
|
+
}
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
private handleTableKey(data: string): void {
|
|
3317
3719
|
if (matchesKey(data, "escape")) {
|
|
3318
|
-
this.
|
|
3319
|
-
|
|
3720
|
+
if (this.changeCount() > 0 && !this.escArmed) {
|
|
3721
|
+
this.feedback = "Unsaved changes. Press esc again to close without saving.";
|
|
3722
|
+
this.escArmed = true;
|
|
3723
|
+
return;
|
|
3724
|
+
}
|
|
3725
|
+
this.finish({ type: "close" });
|
|
3320
3726
|
return;
|
|
3321
3727
|
}
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
);
|
|
3728
|
+
this.escArmed = false;
|
|
3729
|
+
if (matchesKey(data, "left")) return this.switchBy(-1);
|
|
3730
|
+
if (matchesKey(data, "right")) return this.switchBy(1);
|
|
3731
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
3732
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
3328
3733
|
return;
|
|
3329
3734
|
}
|
|
3330
3735
|
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
3331
|
-
this.
|
|
3332
|
-
Math.max(0, options.length - 1),
|
|
3333
|
-
this.modelCursor + 1,
|
|
3334
|
-
);
|
|
3736
|
+
this.cursor = Math.min(this.rows.length - 1, this.cursor + 1);
|
|
3335
3737
|
return;
|
|
3336
3738
|
}
|
|
3337
|
-
if (matchesKey(data, "
|
|
3739
|
+
if (matchesKey(data, "return")) return this.openModel(this.rows[this.cursor] ?? PROFILE_ORCHESTRATOR_KEY);
|
|
3740
|
+
if (matchesKey(data, "e")) return this.openEffort(this.rows[this.cursor] ?? PROFILE_ORCHESTRATOR_KEY);
|
|
3741
|
+
if (matchesKey(data, "s") || matchesKey(data, "ctrl+s")) return this.pressButton("save");
|
|
3742
|
+
if (matchesKey(data, "u")) return this.pressButton("use");
|
|
3743
|
+
if (matchesKey(data, "d")) return this.pressButton("default");
|
|
3744
|
+
if (matchesKey(data, "n")) return this.openMenu("new");
|
|
3745
|
+
if (matchesKey(data, "m")) return this.openMenu("menu");
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
private handleModelKey(data: string): void {
|
|
3749
|
+
const options = this.modelOptions();
|
|
3750
|
+
if (matchesKey(data, "escape")) return this.closeOverlay();
|
|
3751
|
+
if (matchesKey(data, "return")) return this.pickModel(options[this.modelCursor]);
|
|
3752
|
+
if (matchesKey(data, "up")) {
|
|
3338
3753
|
this.modelCursor = Math.max(0, this.modelCursor - 1);
|
|
3339
3754
|
return;
|
|
3340
3755
|
}
|
|
3341
|
-
if (matchesKey(data, "
|
|
3342
|
-
|
|
3343
|
-
if (!selected) return;
|
|
3344
|
-
if (selected === CUSTOM_MODEL) {
|
|
3345
|
-
this.done({
|
|
3346
|
-
type: "custom",
|
|
3347
|
-
agent: this.selectedRow === SET_ALL_AGENTS ? "all" : this.selectedRow,
|
|
3348
|
-
config: this.draft,
|
|
3349
|
-
});
|
|
3350
|
-
return;
|
|
3351
|
-
}
|
|
3352
|
-
if (selected === KEEP_CURRENT) {
|
|
3353
|
-
this.mode = "agents";
|
|
3354
|
-
return;
|
|
3355
|
-
}
|
|
3356
|
-
this.applyModelSelection(
|
|
3357
|
-
selected === INHERIT_MODEL ? undefined : selected,
|
|
3358
|
-
);
|
|
3359
|
-
this.mode = "agents";
|
|
3756
|
+
if (matchesKey(data, "down")) {
|
|
3757
|
+
this.modelCursor = Math.min(options.length - 1, this.modelCursor + 1);
|
|
3360
3758
|
return;
|
|
3361
3759
|
}
|
|
3760
|
+
if (matchesKey(data, "backspace")) {
|
|
3761
|
+
this.query = this.query.slice(0, -1);
|
|
3762
|
+
this.modelCursor = 0;
|
|
3763
|
+
return;
|
|
3764
|
+
}
|
|
3765
|
+
// Every printable key types into the search, so letters never act here.
|
|
3362
3766
|
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
3363
3767
|
this.query += data;
|
|
3364
3768
|
this.modelCursor = 0;
|
|
3365
3769
|
}
|
|
3366
3770
|
}
|
|
3367
3771
|
|
|
3368
|
-
private
|
|
3369
|
-
|
|
3370
|
-
if (
|
|
3371
|
-
|
|
3772
|
+
private handleEffortKey(data: string): void {
|
|
3773
|
+
if (matchesKey(data, "escape")) return this.closeOverlay();
|
|
3774
|
+
if (matchesKey(data, "return")) return this.pickEffort(this.effortCursor);
|
|
3775
|
+
if (matchesKey(data, "left") || matchesKey(data, "up")) {
|
|
3776
|
+
this.effortCursor = Math.max(0, this.effortCursor - 1);
|
|
3372
3777
|
return;
|
|
3373
3778
|
}
|
|
3374
|
-
if (
|
|
3375
|
-
|
|
3779
|
+
if (matchesKey(data, "right") || matchesKey(data, "down")) {
|
|
3780
|
+
this.effortCursor = Math.min(this.effortOptions().length - 1, this.effortCursor + 1);
|
|
3781
|
+
}
|
|
3376
3782
|
}
|
|
3377
3783
|
|
|
3378
|
-
private
|
|
3379
|
-
const
|
|
3380
|
-
if (
|
|
3381
|
-
|
|
3784
|
+
private handleMenuKey(data: string): void {
|
|
3785
|
+
const items = this.menuItems();
|
|
3786
|
+
if (matchesKey(data, "escape")) return this.closeOverlay();
|
|
3787
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
3788
|
+
this.menuCursor = Math.max(0, this.menuCursor - 1);
|
|
3382
3789
|
return;
|
|
3383
3790
|
}
|
|
3384
|
-
|
|
3791
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
3792
|
+
this.menuCursor = Math.min(items.length - 1, this.menuCursor + 1);
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
if (matchesKey(data, "return")) {
|
|
3796
|
+
const item = items[this.menuCursor];
|
|
3797
|
+
if (item) this.runMenuItem(item.id);
|
|
3798
|
+
}
|
|
3385
3799
|
}
|
|
3386
3800
|
|
|
3387
|
-
private
|
|
3388
|
-
|
|
3389
|
-
if (
|
|
3390
|
-
|
|
3391
|
-
|
|
3801
|
+
private handleSwitchKey(data: string): void {
|
|
3802
|
+
if (matchesKey(data, "escape")) return this.pressButton("switch-stay");
|
|
3803
|
+
if (matchesKey(data, "s") || matchesKey(data, "return")) return this.pressButton("switch-save");
|
|
3804
|
+
if (matchesKey(data, "d")) return this.pressButton("switch-discard");
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
// -- pointer ----------------------------------------------------------------
|
|
3808
|
+
|
|
3809
|
+
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
3810
|
+
if (event.type === "wheel" && event.wheelDelta) {
|
|
3811
|
+
const delta = event.wheelDelta < 0 ? -1 : 1;
|
|
3812
|
+
if (this.overlay === "model") {
|
|
3813
|
+
this.modelCursor = Math.max(0, Math.min(this.modelOptions().length - 1, this.modelCursor + delta));
|
|
3814
|
+
} else if (this.overlay === "effort") {
|
|
3815
|
+
this.effortCursor = Math.max(0, Math.min(this.effortOptions().length - 1, this.effortCursor + delta));
|
|
3816
|
+
} else if (this.overlay === "menu" || this.overlay === "new") {
|
|
3817
|
+
this.menuCursor = Math.max(0, Math.min(this.menuItems().length - 1, this.menuCursor + delta));
|
|
3818
|
+
} else if (this.overlay === undefined) {
|
|
3819
|
+
this.cursor = Math.max(0, Math.min(this.rows.length - 1, this.cursor + delta));
|
|
3820
|
+
}
|
|
3821
|
+
return { handled: true, render: true };
|
|
3822
|
+
}
|
|
3823
|
+
const exact = this.hitRegions.find(
|
|
3824
|
+
(region) => region.y === event.y && event.x >= region.x0 && event.x < region.x1,
|
|
3825
|
+
);
|
|
3826
|
+
if (event.type === "move") {
|
|
3827
|
+
if (event.button !== "none") return undefined;
|
|
3828
|
+
// Whole-row targets light up anywhere on their line; buttons and tabs
|
|
3829
|
+
// only under the pointer.
|
|
3830
|
+
const hit = exact ?? this.hitRegions.find((region) => region.y === event.y && this.isRowAction(region.action));
|
|
3831
|
+
const next = hit ? this.hoverKey(hit.action) : undefined;
|
|
3832
|
+
const changed = next !== this.hovered;
|
|
3833
|
+
this.hovered = next;
|
|
3834
|
+
return { handled: hit !== undefined, render: changed };
|
|
3835
|
+
}
|
|
3836
|
+
// A click arrives as press then click: the press only claims the pointer,
|
|
3837
|
+
// so one click never runs two actions.
|
|
3838
|
+
if (event.type === "press") return exact ? { handled: true } : undefined;
|
|
3839
|
+
if (event.type !== "click" || event.button !== "left") return undefined;
|
|
3840
|
+
if (!exact) {
|
|
3841
|
+
if (this.overlay !== undefined && this.overlay !== "switch") {
|
|
3842
|
+
this.closeOverlay();
|
|
3843
|
+
return { handled: true, render: true };
|
|
3844
|
+
}
|
|
3845
|
+
return undefined;
|
|
3392
3846
|
}
|
|
3393
|
-
|
|
3847
|
+
this.dispatch(exact.action);
|
|
3848
|
+
return { handled: true, render: true };
|
|
3394
3849
|
}
|
|
3395
3850
|
|
|
3396
|
-
private
|
|
3397
|
-
|
|
3398
|
-
if (model === undefined) delete current.model;
|
|
3399
|
-
else current.model = model;
|
|
3400
|
-
if (!current.model && !current.thinking) this.draft[name] = {};
|
|
3401
|
-
else this.draft[name] = current;
|
|
3851
|
+
private isRowAction(action: RoutingTableMouseAction): boolean {
|
|
3852
|
+
return action.kind === "row" || action.kind === "model-option" || action.kind === "menu-item";
|
|
3402
3853
|
}
|
|
3403
3854
|
|
|
3404
|
-
private
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3855
|
+
private hoverKey(action: RoutingTableMouseAction): string {
|
|
3856
|
+
switch (action.kind) {
|
|
3857
|
+
case "row":
|
|
3858
|
+
case "effort":
|
|
3859
|
+
return `row:${action.row}`;
|
|
3860
|
+
case "tab":
|
|
3861
|
+
return `tab:${action.name}`;
|
|
3862
|
+
case "model-option":
|
|
3863
|
+
case "effort-option":
|
|
3864
|
+
return `${action.kind}:${action.index}`;
|
|
3865
|
+
case "menu-item":
|
|
3866
|
+
return `menu:${action.id}`;
|
|
3867
|
+
case "button":
|
|
3868
|
+
return `button:${action.button}`;
|
|
3869
|
+
default:
|
|
3870
|
+
return action.kind;
|
|
3871
|
+
}
|
|
3410
3872
|
}
|
|
3411
3873
|
|
|
3412
|
-
private
|
|
3413
|
-
|
|
3874
|
+
private dispatch(action: RoutingTableMouseAction): void {
|
|
3875
|
+
switch (action.kind) {
|
|
3876
|
+
case "tab":
|
|
3877
|
+
this.switchTab(action.name);
|
|
3878
|
+
return;
|
|
3879
|
+
case "tab-menu":
|
|
3880
|
+
this.openMenu("menu");
|
|
3881
|
+
return;
|
|
3882
|
+
case "new":
|
|
3883
|
+
this.openMenu("new");
|
|
3884
|
+
return;
|
|
3885
|
+
case "row":
|
|
3886
|
+
this.cursor = Math.max(0, this.rows.indexOf(action.row));
|
|
3887
|
+
this.openModel(action.row);
|
|
3888
|
+
return;
|
|
3889
|
+
case "effort":
|
|
3890
|
+
this.cursor = Math.max(0, this.rows.indexOf(action.row));
|
|
3891
|
+
this.openEffort(action.row);
|
|
3892
|
+
return;
|
|
3893
|
+
case "model-option":
|
|
3894
|
+
this.pickModel(this.modelOptions()[action.index]);
|
|
3895
|
+
return;
|
|
3896
|
+
case "effort-option":
|
|
3897
|
+
this.pickEffort(action.index);
|
|
3898
|
+
return;
|
|
3899
|
+
case "menu-item":
|
|
3900
|
+
this.runMenuItem(action.id);
|
|
3901
|
+
return;
|
|
3902
|
+
case "button":
|
|
3903
|
+
this.pressButton(action.button);
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3414
3906
|
}
|
|
3415
3907
|
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
);
|
|
3908
|
+
// -- render ----------------------------------------------------------------
|
|
3909
|
+
|
|
3910
|
+
render(width: number): string[] {
|
|
3911
|
+
this.hitRegions = [];
|
|
3912
|
+
const inner = Math.max(20, width - 4);
|
|
3913
|
+
if (this.overlay === undefined) return this.renderFrame(this.tableLines(inner), width);
|
|
3914
|
+
// The table stays on screen, dimmed, under the card; its regions are
|
|
3915
|
+
// dropped because the card is modal.
|
|
3916
|
+
const table = this.tableLines(inner).map((line) => this.renderText(stripAnsi(line), "dim"));
|
|
3917
|
+
this.hitRegions = [];
|
|
3918
|
+
const card = this.cardFor(inner);
|
|
3919
|
+
return this.renderFrame(this.placeCard(table, card, inner), width);
|
|
3920
|
+
}
|
|
3921
|
+
|
|
3922
|
+
private renderFrame(body: string[], width: number): string[] {
|
|
3923
|
+
const inner = Math.max(20, width - 4);
|
|
3924
|
+
const rows = this.terminalRows ? Math.floor(this.terminalRows()) - 2 : body.length;
|
|
3925
|
+
const padded = body.length < rows ? [...body, ...Array<string>(rows - body.length).fill("")] : body.slice(0, Math.max(rows, 1));
|
|
3926
|
+
const rule = "─".repeat(inner + 2);
|
|
3927
|
+
const border = (text: string) => this.renderText(text, "border");
|
|
3928
|
+
return [
|
|
3929
|
+
border(`╭${rule}╮`),
|
|
3930
|
+
...padded.map((line) => `${border("│")} ${this.fit(line, inner)} ${border("│")}`),
|
|
3931
|
+
border(`╰${rule}╯`),
|
|
3932
|
+
];
|
|
3422
3933
|
}
|
|
3423
3934
|
|
|
3424
|
-
private
|
|
3935
|
+
private tableLines(inner: number): string[] {
|
|
3425
3936
|
const lines: string[] = [];
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
const
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
);
|
|
3445
|
-
const
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
const
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
"Continue",
|
|
3462
|
-
this.cursor === this.rows.length ? "accent" : "text",
|
|
3463
|
-
)}`,
|
|
3464
|
-
);
|
|
3937
|
+
// Regions are recorded in frame coordinates: the top border is row 0 and
|
|
3938
|
+
// content starts two columns in ("│ ").
|
|
3939
|
+
const region = (action: RoutingTableMouseAction, x0: number, x1: number) =>
|
|
3940
|
+
this.hitRegions.push({ y: lines.length + 1, x0: 2 + x0, x1: 2 + x1, action });
|
|
3941
|
+
|
|
3942
|
+
// Tabs: every profile, its scopes, the menu on the active one, and New.
|
|
3943
|
+
const tabs = this.tabWindow(inner);
|
|
3944
|
+
let column = 0;
|
|
3945
|
+
let tabLine = "";
|
|
3946
|
+
let activeStart = 0;
|
|
3947
|
+
let activeEnd = 0;
|
|
3948
|
+
const put = (text: string, tone: PanelTone | undefined, action?: RoutingTableMouseAction) => {
|
|
3949
|
+
if (action) region(action, column, column + visibleWidth(text));
|
|
3950
|
+
const styled = tone ? this.renderText(text, tone) : sanitizeTerminalText(text);
|
|
3951
|
+
tabLine += action ? this.hoverWrap(this.hoverKey(action), styled, text) : styled;
|
|
3952
|
+
column += visibleWidth(text);
|
|
3953
|
+
};
|
|
3954
|
+
put(" ", undefined);
|
|
3955
|
+
if (tabs.before) put("‹ ", "dim");
|
|
3956
|
+
for (const name of tabs.names) {
|
|
3957
|
+
const active = name === this.profileName;
|
|
3958
|
+
if (active) activeStart = column;
|
|
3959
|
+
put(` ${truncateToWidth(sanitizeTerminalText(name), 18, "…")} `, active ? "title" : "muted", { kind: "tab", name });
|
|
3960
|
+
for (const glyph of this.scopeGlyphs(name)) put(glyph.text, glyph.tone);
|
|
3961
|
+
if (active) {
|
|
3962
|
+
put(" ▾ ", "accent", { kind: "tab-menu" });
|
|
3963
|
+
activeEnd = column;
|
|
3964
|
+
this.activeTabColumn = activeStart;
|
|
3965
|
+
}
|
|
3966
|
+
put(" ", undefined);
|
|
3967
|
+
}
|
|
3968
|
+
if (tabs.after) put("› ", "dim");
|
|
3969
|
+
this.newColumn = column;
|
|
3970
|
+
put("+ New", "accent", { kind: "new" });
|
|
3971
|
+
lines.push(tabLine);
|
|
3465
3972
|
lines.push(
|
|
3466
|
-
|
|
3467
|
-
"
|
|
3468
|
-
|
|
3469
|
-
)}`,
|
|
3973
|
+
this.renderText("─".repeat(activeStart), "border")
|
|
3974
|
+
+ this.renderText("━".repeat(Math.max(0, activeEnd - activeStart)), "accent")
|
|
3975
|
+
+ this.renderText("─".repeat(Math.max(0, inner - activeEnd)), "border"),
|
|
3470
3976
|
);
|
|
3977
|
+
const title = truncateToWidth(sanitizeTerminalText(this.profileName), Math.max(8, inner - 4), "…");
|
|
3978
|
+
const room = inner - 1 - visibleWidth(title) - 3;
|
|
3979
|
+
const sentence = [this.scopeSentence(this.profileName), this.shortScope(this.profileName)]
|
|
3980
|
+
.find((candidate) => visibleWidth(candidate) <= room) ?? "";
|
|
3981
|
+
lines.push(` ${this.renderText(title, "title")}${sentence ? ` ${this.renderText(sentence, "muted")}` : ""}`);
|
|
3982
|
+
const notices = this.noticeLines(inner);
|
|
3983
|
+
for (const notice of notices) lines.push(this.renderText(notice, "edited"));
|
|
3471
3984
|
lines.push("");
|
|
3472
|
-
|
|
3473
|
-
//
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
)
|
|
3479
|
-
|
|
3985
|
+
|
|
3986
|
+
// Labels are measured after sanitizing: control sequences in an agent name
|
|
3987
|
+
// would otherwise pad the column and then vanish, gluing name and model.
|
|
3988
|
+
const agentCol = Math.min(26, Math.max("Orchestrator".length, ...this.agents.map((agent) => visibleWidth(sanitizeTerminalText(agent)))) + 2);
|
|
3989
|
+
const padLabel = (text: string) => {
|
|
3990
|
+
const safe = truncateToWidth(sanitizeTerminalText(text), agentCol - 1, "…");
|
|
3991
|
+
return safe + " ".repeat(Math.max(0, agentCol - visibleWidth(safe)));
|
|
3992
|
+
};
|
|
3993
|
+
const modelCol = Math.max(20, Math.min(40, inner - agentCol - 12));
|
|
3994
|
+
const effortStart = 2 + agentCol + modelCol;
|
|
3995
|
+
const row = (key: string, label: string, labelTone: PanelTone) => {
|
|
3996
|
+
const focused = this.rows[this.cursor] === key;
|
|
3997
|
+
const model = this.modelValue(key);
|
|
3998
|
+
const effort = this.effortValue(key);
|
|
3999
|
+
const modelTone: PanelTone = this.isEdited(key, "model")
|
|
4000
|
+
? "edited"
|
|
4001
|
+
: model === LEAVE_ORCHESTRATOR_UNTOUCHED || model === "inherit" || model === ROUTING_PI_DEFAULT ? "dim" : "text";
|
|
4002
|
+
const effortTone: PanelTone = this.isEdited(key, "thinking") ? "edited" : effort === "inherit" || effort === ROUTING_PI_DEFAULT ? "dim" : "status";
|
|
4003
|
+
const plain = `${focused ? "▸ " : " "}${padLabel(label)}${truncateToWidth(model, modelCol - 1, "…").padEnd(modelCol)}${effort}`;
|
|
4004
|
+
const styled =
|
|
4005
|
+
this.renderText(focused ? "▸ " : " ", "accent")
|
|
4006
|
+
+ this.renderText(padLabel(label), labelTone)
|
|
4007
|
+
+ this.renderText(truncateToWidth(model, modelCol - 1, "…").padEnd(modelCol), modelTone)
|
|
4008
|
+
+ this.renderText(effort, effortTone);
|
|
4009
|
+
if (effort) region({ kind: "effort", row: key }, effortStart, inner);
|
|
4010
|
+
region({ kind: "row", row: key }, 0, inner);
|
|
4011
|
+
lines.push(this.hoverWrap(`row:${key}`, styled, plain, inner));
|
|
4012
|
+
};
|
|
4013
|
+
|
|
4014
|
+
row(PROFILE_ORCHESTRATOR_KEY, "Orchestrator", "title");
|
|
4015
|
+
lines.push(` ${this.renderText("─".repeat(Math.max(0, effortStart + 8 - 1)), "border")}`);
|
|
4016
|
+
|
|
4017
|
+
// The header doubles as the "every agent" row: Model ▾ and Effort ▾.
|
|
4018
|
+
const headerFocused = this.rows[this.cursor] === ROUTING_ALL_ROW;
|
|
4019
|
+
const headerBase = `${headerFocused ? "▸ " : " "}${"Agent".padEnd(agentCol)}${"Model ▾".padEnd(modelCol)}Effort ▾`;
|
|
4020
|
+
const headerNote = visibleWidth(`${headerBase} set every agent`) <= inner ? " set every agent" : "";
|
|
4021
|
+
const headerPlain = `${headerBase}${headerNote}`;
|
|
4022
|
+
region({ kind: "effort", row: ROUTING_ALL_ROW }, effortStart, effortStart + "Effort ▾".length);
|
|
4023
|
+
region({ kind: "row", row: ROUTING_ALL_ROW }, 0, inner);
|
|
3480
4024
|
lines.push(
|
|
3481
|
-
|
|
3482
|
-
`
|
|
3483
|
-
"
|
|
4025
|
+
this.hoverWrap(
|
|
4026
|
+
`row:${ROUTING_ALL_ROW}`,
|
|
4027
|
+
this.renderText(headerFocused ? "▸ " : " ", "accent")
|
|
4028
|
+
+ this.renderText("Agent".padEnd(agentCol), "muted")
|
|
4029
|
+
+ this.renderText("Model ▾".padEnd(modelCol), "muted")
|
|
4030
|
+
+ this.renderText("Effort ▾", "muted")
|
|
4031
|
+
+ this.renderText(headerNote, "dim"),
|
|
4032
|
+
headerPlain,
|
|
4033
|
+
inner,
|
|
3484
4034
|
),
|
|
3485
4035
|
);
|
|
4036
|
+
|
|
4037
|
+
const window = this.agentWindow(notices.length);
|
|
4038
|
+
if (window.start > 0) lines.push(this.renderText(` ↑ ${window.start} more`, "dim"));
|
|
4039
|
+
for (const agent of this.agents.slice(window.start, window.end)) row(agent, agent, "text");
|
|
4040
|
+
if (window.end < this.agents.length) lines.push(this.renderText(` ↓ ${this.agents.length - window.end} more`, "dim"));
|
|
4041
|
+
lines.push("");
|
|
4042
|
+
|
|
4043
|
+
// Footer: three buttons, the unsaved count on the right.
|
|
4044
|
+
const changes = this.changeCount();
|
|
4045
|
+
const status = changes > 0 ? `● ${changes} unsaved` : "Saved";
|
|
4046
|
+
const buttonsWidth = (labels: (id: RoutingTableButton) => string) =>
|
|
4047
|
+
1 + (["use", "default", "save"] as const).reduce((sum, id) => sum + labels(id).length + 6, 0);
|
|
4048
|
+
const fullLabel = (id: RoutingTableButton) => ROUTING_TABLE_BUTTONS[id];
|
|
4049
|
+
const shortLabel = (id: RoutingTableButton) => ROUTING_TABLE_SHORT_BUTTONS[id] ?? ROUTING_TABLE_BUTTONS[id];
|
|
4050
|
+
const label = buttonsWidth(fullLabel) + status.length + 1 <= inner ? fullLabel : shortLabel;
|
|
4051
|
+
let footer = "";
|
|
4052
|
+
let footerColumn = 0;
|
|
4053
|
+
const button = (id: RoutingTableButton, tone: PanelTone) => {
|
|
4054
|
+
const text = `[ ${label(id)} ]`;
|
|
4055
|
+
region({ kind: "button", button: id }, footerColumn, footerColumn + text.length);
|
|
4056
|
+
footer += this.hoverWrap(`button:${id}`, this.renderText(text, tone), text);
|
|
4057
|
+
footer += " ";
|
|
4058
|
+
footerColumn += text.length + 2;
|
|
4059
|
+
};
|
|
4060
|
+
footer += " ";
|
|
4061
|
+
footerColumn += 1;
|
|
4062
|
+
button("use", "accent");
|
|
4063
|
+
button("default", "accent");
|
|
4064
|
+
button("save", changes > 0 ? "title" : "dim");
|
|
4065
|
+
const gap = Math.max(1, inner - footerColumn - status.length - 1);
|
|
4066
|
+
lines.push(footer + " ".repeat(gap) + this.renderText(status, changes > 0 ? "edited" : "good"));
|
|
4067
|
+
lines.push(this.renderText(this.fitHints(inner), "dim"));
|
|
4068
|
+
const [first = "", second = ""] = this.wrapFeedback(inner);
|
|
4069
|
+
const feedbackTone: PanelTone = this.escArmed ? "edited" : "muted";
|
|
4070
|
+
lines.push(first ? this.renderText(first, feedbackTone) : "");
|
|
4071
|
+
lines.push(second ? this.renderText(second, feedbackTone) : "");
|
|
3486
4072
|
return lines;
|
|
3487
4073
|
}
|
|
3488
4074
|
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
const
|
|
3492
|
-
|
|
3493
|
-
this.renderLine(text, width, tone);
|
|
3494
|
-
lines.push(
|
|
3495
|
-
line(`Select model for ${sanitizeTerminalText(this.selectedRow)}`, "title"),
|
|
3496
|
-
);
|
|
3497
|
-
lines.push("");
|
|
3498
|
-
lines.push(
|
|
3499
|
-
`${this.renderText("◎", "accent")} ${this.renderText(this.query || "search...", "muted")}`,
|
|
4075
|
+
/** The scope in a few characters, for terminals too narrow for the sentence. */
|
|
4076
|
+
private shortScope(name: string): string {
|
|
4077
|
+
const parts = this.scopeGlyphs(name).map((glyph) =>
|
|
4078
|
+
glyph.text === "◉" ? "◉ session" : glyph.text === "★" ? "★ default" : "⌂ repo",
|
|
3500
4079
|
);
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
);
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
4080
|
+
return parts.length > 0 ? parts.join(" ") : "not in use";
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
/** As many key hints as fit, chosen by priority and shown in their usual order. */
|
|
4084
|
+
private fitHints(inner: number): string {
|
|
4085
|
+
const budget = inner - 1;
|
|
4086
|
+
const chosen = new Set<number>();
|
|
4087
|
+
let used = 0;
|
|
4088
|
+
const byPriority = ROUTING_TABLE_HINTS.map((hint, index) => ({ ...hint, index })).sort((a, b) => a.keep - b.keep);
|
|
4089
|
+
for (const hint of byPriority) {
|
|
4090
|
+
const cost = visibleWidth(hint.text) + (chosen.size > 0 ? 2 : 0);
|
|
4091
|
+
if (used + cost > budget) continue;
|
|
4092
|
+
chosen.add(hint.index);
|
|
4093
|
+
used += cost;
|
|
4094
|
+
}
|
|
4095
|
+
return ` ${ROUTING_TABLE_HINTS.filter((_, index) => chosen.has(index)).map((hint) => hint.text).join(" ")}`;
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
/**
|
|
4099
|
+
* Each notice wrapped between words under a ⚠, at most four lines in all so
|
|
4100
|
+
* the table keeps its room; anything beyond is counted, never cut mid-word.
|
|
4101
|
+
*/
|
|
4102
|
+
private noticeLines(inner: number): string[] {
|
|
4103
|
+
const width = Math.max(10, inner - 3);
|
|
4104
|
+
const lines: string[] = [];
|
|
4105
|
+
let shown = 0;
|
|
4106
|
+
for (const notice of this.notices) {
|
|
4107
|
+
const wrapped: string[] = [];
|
|
4108
|
+
let current = "";
|
|
4109
|
+
for (const word of sanitizeTerminalText(notice).split(/\s+/).filter(Boolean)) {
|
|
4110
|
+
const next = current ? `${current} ${word}` : word;
|
|
4111
|
+
if (visibleWidth(next) <= width || current === "") {
|
|
4112
|
+
current = next;
|
|
4113
|
+
continue;
|
|
4114
|
+
}
|
|
4115
|
+
wrapped.push(current);
|
|
4116
|
+
current = word;
|
|
4117
|
+
}
|
|
4118
|
+
if (current) wrapped.push(current);
|
|
4119
|
+
if (lines.length + wrapped.length > 4) break;
|
|
4120
|
+
wrapped.forEach((line, index) => lines.push(`${index === 0 ? " ⚠ " : " "}${line}`));
|
|
4121
|
+
shown += 1;
|
|
3521
4122
|
}
|
|
3522
|
-
|
|
3523
|
-
lines.push("");
|
|
3524
|
-
lines.push(
|
|
3525
|
-
line("j/k: navigate • type: search • enter: select • esc: back", "muted"),
|
|
3526
|
-
);
|
|
4123
|
+
const hidden = this.notices.length - shown;
|
|
4124
|
+
if (hidden > 0) lines.push(` ${hidden} more problem${hidden === 1 ? "" : "s"} not shown.`);
|
|
3527
4125
|
return lines;
|
|
3528
4126
|
}
|
|
3529
4127
|
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
4128
|
+
/** The feedback message on up to two lines, broken between words. */
|
|
4129
|
+
private wrapFeedback(inner: number): string[] {
|
|
4130
|
+
if (!this.feedback) return [];
|
|
4131
|
+
const width = Math.max(10, inner - 1);
|
|
4132
|
+
const lines: string[] = [];
|
|
4133
|
+
let current = "";
|
|
4134
|
+
for (const word of sanitizeTerminalText(this.feedback).split(/\s+/).filter(Boolean)) {
|
|
4135
|
+
const next = current ? `${current} ${word}` : word;
|
|
4136
|
+
if (visibleWidth(next) <= width) {
|
|
4137
|
+
current = next;
|
|
4138
|
+
continue;
|
|
4139
|
+
}
|
|
4140
|
+
if (current) lines.push(current);
|
|
4141
|
+
current = word;
|
|
4142
|
+
}
|
|
4143
|
+
if (current) lines.push(current);
|
|
4144
|
+
if (lines.length > 2) {
|
|
4145
|
+
lines[1] = truncateToWidth(lines.slice(1).join(" "), width, "…");
|
|
4146
|
+
lines.length = 2;
|
|
4147
|
+
}
|
|
4148
|
+
return lines.map((line) => ` ${line}`);
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
/** Which tabs fit around the active one, and whether more hide on either side. */
|
|
4152
|
+
private tabWindow(inner: number): { names: string[]; before: boolean; after: boolean } {
|
|
4153
|
+
const names = this.profileNames();
|
|
4154
|
+
const widthOf = (name: string) =>
|
|
4155
|
+
Math.min(18, visibleWidth(name)) + 4 + this.scopeGlyphs(name).length + (name === this.profileName ? 3 : 0);
|
|
4156
|
+
const budget = inner - 1 - "+ New".length - 4;
|
|
4157
|
+
const active = Math.max(0, names.indexOf(this.profileName));
|
|
4158
|
+
let start = active;
|
|
4159
|
+
let end = active + 1;
|
|
4160
|
+
let used = widthOf(names[active] ?? "");
|
|
4161
|
+
for (let grew = true; grew;) {
|
|
4162
|
+
grew = false;
|
|
4163
|
+
if (end < names.length && used + widthOf(names[end]) <= budget) {
|
|
4164
|
+
used += widthOf(names[end]);
|
|
4165
|
+
end += 1;
|
|
4166
|
+
grew = true;
|
|
4167
|
+
}
|
|
4168
|
+
if (start > 0 && used + widthOf(names[start - 1]) <= budget) {
|
|
4169
|
+
start -= 1;
|
|
4170
|
+
used += widthOf(names[start]);
|
|
4171
|
+
grew = true;
|
|
4172
|
+
}
|
|
3534
4173
|
}
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
4174
|
+
return { names: names.slice(start, end), before: start > 0, after: end < names.length };
|
|
4175
|
+
}
|
|
4176
|
+
|
|
4177
|
+
private agentWindow(noticeRows = 0): { start: number; end: number } {
|
|
4178
|
+
const total = this.agents.length;
|
|
4179
|
+
if (!this.terminalRows) return { start: 0, end: total };
|
|
4180
|
+
const room = Math.max(3, Math.floor(this.terminalRows()) - 2 - ROUTING_TABLE_FIXED_ROWS - noticeRows);
|
|
4181
|
+
if (total <= room) return { start: 0, end: total };
|
|
4182
|
+
const visible = Math.max(1, room - 2);
|
|
4183
|
+
const focus = Math.max(0, this.cursor - 2);
|
|
4184
|
+
const start = Math.max(0, Math.min(focus - Math.floor(visible / 2), total - visible));
|
|
4185
|
+
return { start, end: start + visible };
|
|
4186
|
+
}
|
|
4187
|
+
|
|
4188
|
+
// -- cards -------------------------------------------------------------------
|
|
4189
|
+
|
|
4190
|
+
private cardFor(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4191
|
+
switch (this.overlay) {
|
|
4192
|
+
case "model":
|
|
4193
|
+
return this.modelCard(inner);
|
|
4194
|
+
case "effort":
|
|
4195
|
+
return this.effortCard(inner);
|
|
4196
|
+
case "menu":
|
|
4197
|
+
return this.menuCard(inner, this.activeTabColumn);
|
|
4198
|
+
case "new":
|
|
4199
|
+
return this.menuCard(inner, this.newColumn);
|
|
4200
|
+
default:
|
|
4201
|
+
return this.switchCard(inner);
|
|
3538
4202
|
}
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
private modelCard(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4206
|
+
const width = Math.min(56, inner - 4);
|
|
4207
|
+
const content = width - 4;
|
|
4208
|
+
const lines: string[] = [];
|
|
4209
|
+
const region = (action: RoutingTableMouseAction) =>
|
|
4210
|
+
this.hitRegions.push({ y: lines.length, x0: 0, x1: content, action });
|
|
4211
|
+
lines.push(this.renderText("Model for ", "muted") + this.renderText(this.rowLabel(this.target), "title"));
|
|
4212
|
+
lines.push(this.renderText("⌕ ", "accent") + (this.query ? sanitizeTerminalText(this.query) : this.renderText("type to search", "dim")));
|
|
4213
|
+
lines.push(this.renderText("─".repeat(content), "border"));
|
|
4214
|
+
const options = this.modelOptions();
|
|
4215
|
+
const current = this.currentValue(this.target, "model") ?? (this.target === ROUTING_ALL_ROW ? undefined : this.inheritLabel(this.target));
|
|
4216
|
+
const rows = this.terminalRows ? Math.floor(this.terminalRows()) : options.length + ROUTING_MODEL_CARD_CHROME_ROWS;
|
|
4217
|
+
const visible = Math.max(3, Math.min(options.length, rows - ROUTING_MODEL_CARD_CHROME_ROWS));
|
|
4218
|
+
const start = Math.max(0, Math.min(this.modelCursor - Math.floor(visible / 2), options.length - visible));
|
|
4219
|
+
for (let index = start; index < Math.min(options.length, start + visible); index += 1) {
|
|
4220
|
+
const option = options[index] ?? "";
|
|
4221
|
+
const focused = index === this.modelCursor;
|
|
4222
|
+
const tone: PanelTone = focused ? "title" : option === ROUTING_CUSTOM_MODEL || option === this.inheritLabel(this.target) ? "muted" : "text";
|
|
4223
|
+
const mark = option === current ? " ✓" : "";
|
|
4224
|
+
const plain = `${focused ? "▸ " : " "}${option}${mark}`;
|
|
4225
|
+
region({ kind: "model-option", index });
|
|
4226
|
+
lines.push(
|
|
4227
|
+
this.hoverWrap(
|
|
4228
|
+
`model-option:${index}`,
|
|
4229
|
+
this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(option, tone) + this.renderText(mark, "good"),
|
|
4230
|
+
plain,
|
|
4231
|
+
content,
|
|
4232
|
+
),
|
|
3543
4233
|
);
|
|
3544
|
-
return;
|
|
3545
4234
|
}
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
4235
|
+
lines.push("");
|
|
4236
|
+
lines.push(this.renderText("↑↓ choose · enter pick · esc back", "dim"));
|
|
4237
|
+
return { lines, width, top: 4, left: Math.max(0, Math.floor((inner - width) / 2)) };
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
private effortCard(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4241
|
+
// Provider review roles inherit Pi's persisted default, so their inherit
|
|
4242
|
+
// choice says so (gentle-pi#2271).
|
|
4243
|
+
const inheritLabel = this.target !== ROUTING_ALL_ROW && isProviderReviewRole(this.target)
|
|
4244
|
+
? routingDefaultLabel(this.target, "effort")
|
|
4245
|
+
: "inherit";
|
|
4246
|
+
const chips = this.effortOptions().map((option, index) => {
|
|
4247
|
+
const label = option === INHERIT_THINKING ? inheritLabel : option;
|
|
4248
|
+
const text = index === this.effortCursor ? `[${label}]` : ` ${label} `;
|
|
4249
|
+
return { index, text };
|
|
4250
|
+
});
|
|
4251
|
+
const oneRow = chips.reduce((sum, chip) => sum + chip.text.length + 1, 0) - 1;
|
|
4252
|
+
const width = Math.min(inner - 4, Math.max(44, oneRow + 4));
|
|
4253
|
+
const content = width - 4;
|
|
4254
|
+
// One row when it fits; otherwise the levels wrap onto a second row, so a
|
|
4255
|
+
// narrow terminal never cuts a level in half.
|
|
4256
|
+
const rows: Array<typeof chips> = [[]];
|
|
4257
|
+
let used = 0;
|
|
4258
|
+
for (const chip of chips) {
|
|
4259
|
+
const cost = chip.text.length + (rows[rows.length - 1].length > 0 ? 1 : 0);
|
|
4260
|
+
if (used + cost > content && rows[rows.length - 1].length > 0) {
|
|
4261
|
+
rows.push([]);
|
|
4262
|
+
used = 0;
|
|
4263
|
+
}
|
|
4264
|
+
rows[rows.length - 1].push(chip);
|
|
4265
|
+
used += chip.text.length + (rows[rows.length - 1].length > 1 ? 1 : 0);
|
|
4266
|
+
}
|
|
4267
|
+
const lines: string[] = [];
|
|
4268
|
+
lines.push(this.renderText("Effort for ", "muted") + this.renderText(this.rowLabel(this.target), "title"));
|
|
4269
|
+
lines.push(this.renderText(truncateToWidth(this.effortSource(), content, "…"), "dim"));
|
|
4270
|
+
for (const row of rows) {
|
|
4271
|
+
let column = 0;
|
|
4272
|
+
const drawn: string[] = [];
|
|
4273
|
+
for (const chip of row) {
|
|
4274
|
+
this.hitRegions.push({
|
|
4275
|
+
y: lines.length,
|
|
4276
|
+
x0: column,
|
|
4277
|
+
x1: column + chip.text.length,
|
|
4278
|
+
action: { kind: "effort-option", index: chip.index },
|
|
4279
|
+
});
|
|
4280
|
+
const focused = chip.index === this.effortCursor;
|
|
4281
|
+
drawn.push(this.hoverWrap(`effort-option:${chip.index}`, this.renderText(chip.text, focused ? "title" : "status"), chip.text));
|
|
4282
|
+
column += chip.text.length + 1;
|
|
4283
|
+
}
|
|
4284
|
+
lines.push(drawn.join(" "));
|
|
4285
|
+
}
|
|
4286
|
+
lines.push("");
|
|
4287
|
+
lines.push(this.renderText("←→ choose · enter pick · esc back", "dim"));
|
|
4288
|
+
return { lines, width, top: 6, left: Math.max(0, Math.floor((inner - width) / 2)) };
|
|
4289
|
+
}
|
|
4290
|
+
|
|
4291
|
+
/** One line under the effort card's title: whose levels these are. */
|
|
4292
|
+
private effortSource(): string {
|
|
4293
|
+
const supported = this.supportedLevels(this.target);
|
|
4294
|
+
if (this.target === ROUTING_ALL_ROW) {
|
|
4295
|
+
return supported ? "Levels every agent's model supports." : "Models unknown to the catalog: every level is shown.";
|
|
3549
4296
|
}
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
4297
|
+
const model = this.draft[this.target]?.model;
|
|
4298
|
+
if (model === undefined) return "Inherits its model: every level is shown.";
|
|
4299
|
+
return supported ? `Levels ${model} supports.` : `${model} is not in the catalog: every level is shown.`;
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
private menuCard(inner: number, anchor: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4303
|
+
const items = this.menuItems();
|
|
4304
|
+
const width = Math.min(inner - 2, Math.max(28, ...items.map((item) => visibleWidth(item.label) + 8)));
|
|
4305
|
+
const content = width - 4;
|
|
4306
|
+
const lines: string[] = [];
|
|
4307
|
+
items.forEach((item, index) => {
|
|
4308
|
+
if (item.id === "delete") lines.push(this.renderText("─".repeat(content), "border"));
|
|
4309
|
+
const focused = index === this.menuCursor;
|
|
4310
|
+
const plain = `${focused ? "▸ " : " "}${item.label}`;
|
|
4311
|
+
this.hitRegions.push({ y: lines.length, x0: 0, x1: content, action: { kind: "menu-item", id: item.id } });
|
|
4312
|
+
lines.push(
|
|
4313
|
+
this.hoverWrap(
|
|
4314
|
+
`menu:${item.id}`,
|
|
4315
|
+
this.renderText(focused ? "▸ " : " ", "accent") + this.renderText(item.label, item.tone ?? (focused ? "title" : "text")),
|
|
4316
|
+
plain,
|
|
4317
|
+
content,
|
|
4318
|
+
),
|
|
4319
|
+
);
|
|
4320
|
+
});
|
|
4321
|
+
return { lines, width, top: 2, left: Math.max(0, Math.min(anchor, inner - width)) };
|
|
3555
4322
|
}
|
|
3556
4323
|
|
|
3557
|
-
private
|
|
4324
|
+
private switchCard(inner: number): { lines: string[]; width: number; top: number; left: number } {
|
|
4325
|
+
const width = Math.min(inner - 4, 60);
|
|
4326
|
+
const changes = this.changeCount();
|
|
3558
4327
|
const lines: string[] = [];
|
|
3559
|
-
const line = (text = "", tone?: PanelTone) =>
|
|
3560
|
-
this.renderLine(text, width, tone);
|
|
3561
4328
|
lines.push(
|
|
3562
|
-
|
|
4329
|
+
this.renderText(sanitizeTerminalText(this.profileName), "title")
|
|
4330
|
+
+ this.renderText(` has ${changes} unsaved change${changes === 1 ? "" : "s"}.`, "text"),
|
|
3563
4331
|
);
|
|
3564
4332
|
lines.push("");
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
4333
|
+
let column = 0;
|
|
4334
|
+
let buttons = "";
|
|
4335
|
+
for (const id of ["switch-save", "switch-discard", "switch-stay"] as const) {
|
|
4336
|
+
const text = `[ ${ROUTING_TABLE_BUTTONS[id]} ]`;
|
|
4337
|
+
this.hitRegions.push({ y: lines.length, x0: column, x1: column + text.length, action: { kind: "button", button: id } });
|
|
4338
|
+
buttons += this.hoverWrap(`button:${id}`, this.renderText(text, id === "switch-save" ? "title" : id === "switch-stay" ? "dim" : "accent"), text) + " ";
|
|
4339
|
+
column += text.length + 2;
|
|
4340
|
+
}
|
|
4341
|
+
lines.push(buttons);
|
|
4342
|
+
lines.push(this.renderText("enter save and switch · d discard · esc stay", "dim"));
|
|
4343
|
+
return { lines, width, top: 6, left: Math.max(0, Math.floor((inner - width) / 2)) };
|
|
4344
|
+
}
|
|
4345
|
+
|
|
4346
|
+
/**
|
|
4347
|
+
* Draws a card over the dimmed table. Card regions were recorded relative to
|
|
4348
|
+
* the card content; they move into frame coordinates here: frame border and
|
|
4349
|
+
* "│ " before the body, the card's own border and "│ " before its content.
|
|
4350
|
+
*/
|
|
4351
|
+
private placeCard(
|
|
4352
|
+
table: string[],
|
|
4353
|
+
card: { lines: string[]; width: number; top: number; left: number },
|
|
4354
|
+
inner: number,
|
|
4355
|
+
): string[] {
|
|
4356
|
+
const regions = this.hitRegions;
|
|
4357
|
+
this.hitRegions = regions.map((region) => ({
|
|
4358
|
+
...region,
|
|
4359
|
+
y: region.y + card.top + 2,
|
|
4360
|
+
x0: 2 + card.left + 2 + region.x0,
|
|
4361
|
+
x1: 2 + card.left + 2 + region.x1,
|
|
4362
|
+
}));
|
|
4363
|
+
const content = card.width - 4;
|
|
4364
|
+
const rule = "─".repeat(card.width - 2);
|
|
4365
|
+
const drawn = [
|
|
4366
|
+
this.renderText(`╭${rule}╮`, "accent"),
|
|
4367
|
+
...card.lines.map((line) => `${this.renderText("│", "accent")} ${this.fit(line, content)} ${this.renderText("│", "accent")}`),
|
|
4368
|
+
this.renderText(`╰${rule}╯`, "accent"),
|
|
4369
|
+
];
|
|
4370
|
+
const body = [...table];
|
|
4371
|
+
const rows = Math.max(body.length, card.top + drawn.length);
|
|
4372
|
+
while (body.length < rows) body.push("");
|
|
4373
|
+
for (let index = 0; index < drawn.length; index += 1) {
|
|
4374
|
+
const target = card.top + index;
|
|
4375
|
+
const right = " ".repeat(Math.max(0, inner - card.left - card.width));
|
|
4376
|
+
body[target] = " ".repeat(card.left) + drawn[index] + right;
|
|
3575
4377
|
}
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
4378
|
+
return body;
|
|
4379
|
+
}
|
|
4380
|
+
|
|
4381
|
+
// -- text helpers --------------------------------------------------------------
|
|
4382
|
+
|
|
4383
|
+
/**
|
|
4384
|
+
* The hover fill. It uses the theme's selection background, which reads on
|
|
4385
|
+
* every bundled theme; the pending-tool background it replaced is a shade
|
|
4386
|
+
* away from the terminal background on Gentleman-Cute and never showed.
|
|
4387
|
+
*/
|
|
4388
|
+
private hoverWrap(key: string, styled: string, plain: string, fillTo?: number): string {
|
|
4389
|
+
if (this.hovered !== key || !this.theme) return styled;
|
|
4390
|
+
const text = fillTo === undefined ? plain : plain + " ".repeat(Math.max(0, fillTo - visibleWidth(plain)));
|
|
4391
|
+
return this.theme.bg("selectedBg", this.theme.fg("text", sanitizeTerminalText(text)));
|
|
3579
4392
|
}
|
|
3580
4393
|
|
|
3581
|
-
private
|
|
3582
|
-
const
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
const
|
|
3589
|
-
const
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
return `${this.renderText(sanitizeTerminalText(row).padEnd(20), "text")} ${this.renderText("model=", "muted")}${this.renderText(model, "status")}${this.renderText(
|
|
3606
|
-
", effort=",
|
|
3607
|
-
"muted",
|
|
3608
|
-
)}${this.renderText(effort, "status")}`;
|
|
3609
|
-
}
|
|
3610
|
-
}
|
|
3611
|
-
|
|
3612
|
-
function renderSddModelPanelForTesting(
|
|
3613
|
-
initialConfig: AgentModelConfig,
|
|
4394
|
+
private renderText(text: string, tone: PanelTone): string {
|
|
4395
|
+
const safe = sanitizeTerminalText(text);
|
|
4396
|
+
if (!this.theme) return safe;
|
|
4397
|
+
return this.theme.fg(PANEL_TONE_COLOR[tone] ?? "text", safe);
|
|
4398
|
+
}
|
|
4399
|
+
|
|
4400
|
+
private fit(line: string, width: number): string {
|
|
4401
|
+
const plain = stripAnsi(line);
|
|
4402
|
+
const visible = visibleWidth(plain);
|
|
4403
|
+
if (visible > width) return truncateToWidth(plain, Math.max(1, width), "…", true);
|
|
4404
|
+
return `${line}${" ".repeat(Math.max(0, width - visible))}`;
|
|
4405
|
+
}
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
interface RoutingTableTestEvent {
|
|
4409
|
+
type: "move" | "click" | "wheel";
|
|
4410
|
+
x: number;
|
|
4411
|
+
y: number;
|
|
4412
|
+
button?: string;
|
|
4413
|
+
wheelDelta?: number;
|
|
4414
|
+
}
|
|
4415
|
+
|
|
4416
|
+
function renderRoutingTableForTesting(
|
|
4417
|
+
draft: AgentModelConfig,
|
|
3614
4418
|
modelOptions: string[],
|
|
3615
4419
|
agents: string[],
|
|
3616
4420
|
width: number,
|
|
3617
4421
|
theme?: Theme,
|
|
3618
4422
|
terminalRows?: number,
|
|
3619
4423
|
inputs: string[] = [],
|
|
4424
|
+
mouseEvents: RoutingTableTestEvent[] = [],
|
|
4425
|
+
results: RoutingTableResult[] = [],
|
|
4426
|
+
options: {
|
|
4427
|
+
file?: AgentProfilesFile;
|
|
4428
|
+
sessionBoundName?: string;
|
|
4429
|
+
pinWinner?: string;
|
|
4430
|
+
profileName?: string;
|
|
4431
|
+
feedback?: string;
|
|
4432
|
+
thinkingSupport?: Record<string, readonly ThinkingLevel[]>;
|
|
4433
|
+
notices?: string[];
|
|
4434
|
+
} = {},
|
|
3620
4435
|
): string[] {
|
|
3621
|
-
const panel = new
|
|
3622
|
-
|
|
4436
|
+
const panel = new RoutingTablePanel(
|
|
4437
|
+
draft,
|
|
3623
4438
|
modelOptions,
|
|
3624
4439
|
agents,
|
|
3625
|
-
() =>
|
|
4440
|
+
(result) => results.push(result),
|
|
3626
4441
|
theme,
|
|
3627
|
-
|
|
4442
|
+
options.profileName ?? "current",
|
|
3628
4443
|
terminalRows === undefined ? undefined : () => terminalRows,
|
|
4444
|
+
options,
|
|
3629
4445
|
);
|
|
3630
|
-
|
|
4446
|
+
panel.render(width);
|
|
4447
|
+
for (const data of inputs) {
|
|
4448
|
+
panel.handleInput(data);
|
|
4449
|
+
panel.render(width);
|
|
4450
|
+
}
|
|
4451
|
+
const send = (event: RoutingTableTestEvent, type: TuiMouseEvent["type"]) =>
|
|
4452
|
+
panel.handleMouse({
|
|
4453
|
+
type,
|
|
4454
|
+
x: event.x,
|
|
4455
|
+
y: event.y,
|
|
4456
|
+
button: event.button ?? (type === "move" ? "none" : "left"),
|
|
4457
|
+
wheelDelta: event.wheelDelta,
|
|
4458
|
+
screenX: event.x,
|
|
4459
|
+
screenY: event.y,
|
|
4460
|
+
width,
|
|
4461
|
+
height: terminalRows ?? 40,
|
|
4462
|
+
} as TuiMouseEvent);
|
|
4463
|
+
for (const event of mouseEvents) {
|
|
4464
|
+
// Real terminals send press then click; replay both so a test catches
|
|
4465
|
+
// any action that would fire twice.
|
|
4466
|
+
if (event.type === "click") send(event, "press");
|
|
4467
|
+
send(event, event.type);
|
|
4468
|
+
panel.render(width);
|
|
4469
|
+
}
|
|
3631
4470
|
return panel.render(width);
|
|
3632
4471
|
}
|
|
3633
4472
|
|
|
3634
|
-
|
|
4473
|
+
interface RoutingTablePanelRequest {
|
|
4474
|
+
name: string;
|
|
4475
|
+
config: AgentModelConfig;
|
|
4476
|
+
file: AgentProfilesFile;
|
|
4477
|
+
sessionBoundName?: string;
|
|
4478
|
+
pinWinner?: string;
|
|
4479
|
+
feedback?: string;
|
|
4480
|
+
notices?: string[];
|
|
4481
|
+
switchProfile?: RoutingProfileSwitcher;
|
|
4482
|
+
}
|
|
4483
|
+
|
|
4484
|
+
/**
|
|
4485
|
+
* What the models screen must not hide: a pin layer that does not resolve
|
|
4486
|
+
* (invalid file, or a name no profile defines) silently returns the repository
|
|
4487
|
+
* to the global routing, and a routing key no agent matches is silently ignored
|
|
4488
|
+
* at launch (issues #2196 and #2400).
|
|
4489
|
+
*/
|
|
4490
|
+
function routingScreenNotices(
|
|
3635
4491
|
ctx: ExtensionContext,
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
4492
|
+
file: AgentProfilesFile,
|
|
4493
|
+
shownName: string,
|
|
4494
|
+
sessionBoundName: string | undefined,
|
|
4495
|
+
): string[] {
|
|
4496
|
+
const notices: string[] = [];
|
|
4497
|
+
const pins = evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles);
|
|
4498
|
+
for (const issue of pins.invalid) {
|
|
4499
|
+
notices.push(`The ${issue.source} pin file ${issue.path} is not a valid pin and is ignored. Fix or remove it.`);
|
|
4500
|
+
}
|
|
4501
|
+
for (const issue of pins.stale) {
|
|
4502
|
+
notices.push(`The ${issue.source} pin names "${issue.profile}", which no profile defines, so it is ignored (${issue.path}).`);
|
|
4503
|
+
}
|
|
4504
|
+
const roots = {
|
|
4505
|
+
cwd: ctx.sessionManager?.getCwd?.() ?? ctx.cwd,
|
|
4506
|
+
home: homedir(),
|
|
4507
|
+
agentHome: resolveGentlePiAgentHome(),
|
|
4508
|
+
};
|
|
4509
|
+
let discovered: Array<{ name: string }> = [];
|
|
4510
|
+
try {
|
|
4511
|
+
discovered = discoverAgents(roots).agents;
|
|
4512
|
+
} catch {
|
|
4513
|
+
return notices;
|
|
4514
|
+
}
|
|
4515
|
+
// gentle-pi#2271: provider review roles are routed by key and never have an
|
|
4516
|
+
// agent file, so they always count as matched.
|
|
4517
|
+
const known = [...PROVIDER_REVIEW_ROLES.map((name) => ({ name })), ...discovered];
|
|
4518
|
+
const profileKeys = Object.keys(normalizeModelConfig(file.profiles[shownName]) ?? {}).filter((key) => !isProfileOrchestratorKey(key));
|
|
4519
|
+
const unmatched = unmatchedProfileKeys(Object.fromEntries(profileKeys.map((key) => [key, { model: undefined, thinking: undefined }])), known);
|
|
4520
|
+
const ignored = unmatched.filter((entry) => entry.suggestion === undefined).map((entry) => entry.key);
|
|
4521
|
+
if (ignored.length > 0) {
|
|
4522
|
+
notices.push(`No agent matches ${ignored.map((key) => `"${key}"`).join(", ")} in this profile, so ${ignored.length === 1 ? "it is" : "they are"} ignored at launch.`);
|
|
4523
|
+
}
|
|
4524
|
+
for (const entry of unmatched) {
|
|
4525
|
+
if (entry.suggestion !== undefined) notices.push(`No agent is named "${entry.key}" in this profile; did you mean ${entry.suggestion}?`);
|
|
4526
|
+
}
|
|
4527
|
+
// Issue #2400: model_profiles keys in subagents.json that match nothing and
|
|
4528
|
+
// suggest nothing. A session binding or a winning pin replaces those launches'
|
|
4529
|
+
// routing, so the file's keys do not apply there.
|
|
4530
|
+
if (sessionBoundName === undefined && pinnedEffectiveModelConfig(ctx.cwd) === undefined) {
|
|
4531
|
+
const stray = unmatchedProfileKeys(loadAgentsConfig(roots).modelProfiles, discovered)
|
|
4532
|
+
.filter((entry) => entry.suggestion === undefined && !isProviderReviewRole(entry.key) && !profileKeys.includes(entry.key))
|
|
4533
|
+
.map((entry) => entry.key);
|
|
4534
|
+
if (stray.length > 0) {
|
|
4535
|
+
notices.push(`subagents.json routes ${stray.map((key) => `"${key}"`).join(", ")}, which no agent matches, so ${stray.length === 1 ? "it is" : "they are"} ignored.`);
|
|
4536
|
+
}
|
|
4537
|
+
}
|
|
4538
|
+
return notices;
|
|
4539
|
+
}
|
|
4540
|
+
|
|
4541
|
+
async function showRoutingTablePanel(
|
|
4542
|
+
ctx: ExtensionContext,
|
|
4543
|
+
request: RoutingTablePanelRequest,
|
|
4544
|
+
): Promise<RoutingTableResult> {
|
|
3639
4545
|
const modelOptions = await getPiModelOptions(ctx);
|
|
4546
|
+
const thinkingSupport = await getPiThinkingSupport(ctx);
|
|
3640
4547
|
const agents = modelAssignmentNames(ctx.cwd);
|
|
3641
|
-
return ctx.ui.custom<
|
|
3642
|
-
(tui, theme, _keybindings, done) =>
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
4548
|
+
return ctx.ui.custom<RoutingTableResult>(
|
|
4549
|
+
(tui, theme, _keybindings, done) => {
|
|
4550
|
+
const panel = new RoutingTablePanel(
|
|
4551
|
+
request.config,
|
|
4552
|
+
modelOptions,
|
|
4553
|
+
agents,
|
|
4554
|
+
done,
|
|
4555
|
+
theme,
|
|
4556
|
+
request.name,
|
|
4557
|
+
() => Math.max(0, tui.terminal.rows),
|
|
4558
|
+
{
|
|
4559
|
+
file: request.file,
|
|
4560
|
+
sessionBoundName: request.sessionBoundName,
|
|
4561
|
+
pinWinner: request.pinWinner,
|
|
4562
|
+
feedback: request.feedback,
|
|
4563
|
+
thinkingSupport,
|
|
4564
|
+
notices: request.notices,
|
|
4565
|
+
switchProfile: request.switchProfile,
|
|
4566
|
+
},
|
|
4567
|
+
);
|
|
4568
|
+
const container = createNativeFullscreenInteraction({
|
|
4569
|
+
keyboardTarget: panel,
|
|
4570
|
+
requestRender: () => tui.requestRender(),
|
|
4571
|
+
});
|
|
4572
|
+
container.addChild(panel);
|
|
4573
|
+
return container;
|
|
4574
|
+
},
|
|
3646
4575
|
{
|
|
3647
4576
|
overlay: true,
|
|
3648
|
-
// Same fullscreen dimensions as the `/gentle:profiles` panel.
|
|
3649
4577
|
overlayOptions: {
|
|
3650
4578
|
anchor: "center",
|
|
3651
4579
|
width: "100%",
|
|
@@ -3656,240 +4584,96 @@ async function showSddModelPanel(
|
|
|
3656
4584
|
);
|
|
3657
4585
|
}
|
|
3658
4586
|
|
|
3659
|
-
|
|
4587
|
+
/**
|
|
4588
|
+
* Bare `/gentle:profiles` opens the models screen on the profile this
|
|
4589
|
+
* repository resolves, after the routing preflight: legacy project overrides
|
|
4590
|
+
* migrate and a pinned repository is named before any edit. An unreadable
|
|
4591
|
+
* saved routing file does not block the screen; Make default discloses it.
|
|
4592
|
+
*/
|
|
4593
|
+
async function openModelsScreen(ctx: ExtensionContext, live: LiveSession): Promise<void> {
|
|
3660
4594
|
migrateLegacyProjectModelOverrides(ctx.cwd);
|
|
3661
4595
|
// A pinned repository resolves its subagent routing from the profile at launch,
|
|
3662
|
-
// so global routing written
|
|
3663
|
-
// the edits, not after them, is the difference between a note and a surprise.
|
|
4596
|
+
// so global routing written elsewhere will not reach its subagents. Saying it
|
|
4597
|
+
// before the edits, not after them, is the difference between a note and a surprise.
|
|
3664
4598
|
const pinNote = profilePinScopeNote(ctx.cwd);
|
|
3665
4599
|
if (pinNote) ctx.ui.notify(pinNote, "info");
|
|
3666
|
-
const
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
"warning",
|
|
3674
|
-
);
|
|
3675
|
-
return;
|
|
3676
|
-
}
|
|
3677
|
-
let config = savedConfig.status === "valid" ? savedConfig.config : {};
|
|
3678
|
-
const profileLabel = describeCurrentProfileTarget(resolveCurrentProfileTarget(ctx.cwd));
|
|
3679
|
-
let result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3680
|
-
while (result.type === "custom" || result.type === "export" || result.type === "restore") {
|
|
3681
|
-
config = cloneModelConfig(result.config);
|
|
3682
|
-
if (result.type === "export") {
|
|
3683
|
-
try {
|
|
3684
|
-
const count = await exportSavedModelConfig(ctx);
|
|
3685
|
-
ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info");
|
|
3686
|
-
} catch (error) {
|
|
3687
|
-
ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3688
|
-
}
|
|
3689
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3690
|
-
continue;
|
|
3691
|
-
}
|
|
3692
|
-
if (result.type === "restore") {
|
|
3693
|
-
const restored = await readModelExport(ctx);
|
|
3694
|
-
if (!restored) {
|
|
3695
|
-
ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning");
|
|
3696
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3697
|
-
continue;
|
|
3698
|
-
}
|
|
3699
|
-
const approved = await ctx.ui.confirm("Restore saved model routing?", `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`);
|
|
3700
|
-
if (approved) {
|
|
3701
|
-
try {
|
|
3702
|
-
await writeModelConfigAsync(ctx.cwd, restored);
|
|
3703
|
-
} catch (error) {
|
|
3704
|
-
ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3705
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3706
|
-
continue;
|
|
3707
|
-
}
|
|
3708
|
-
config = restored;
|
|
3709
|
-
try {
|
|
3710
|
-
const applyResult = await applyModelConfigAsync(ctx.cwd, restored);
|
|
3711
|
-
ctx.ui.notify([
|
|
3712
|
-
"el Gentleman restored global model config.",
|
|
3713
|
-
`Import: ${modelExportPath(ctx.cwd)}`,
|
|
3714
|
-
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3715
|
-
`Agents updated: ${applyResult.updated}`,
|
|
3716
|
-
].join("\n"), "info");
|
|
3717
|
-
} catch (error) {
|
|
3718
|
-
ctx.ui.notify([
|
|
3719
|
-
"el Gentleman restored global model config, but applying it to agents failed.",
|
|
3720
|
-
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3721
|
-
`Apply error: ${error instanceof Error ? error.message : String(error)}`,
|
|
3722
|
-
].join("\n"), "warning");
|
|
3723
|
-
}
|
|
3724
|
-
}
|
|
3725
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3726
|
-
continue;
|
|
3727
|
-
}
|
|
3728
|
-
const current =
|
|
3729
|
-
result.agent === "all"
|
|
3730
|
-
? "inherit"
|
|
3731
|
-
: (config[result.agent]?.model ?? "inherit");
|
|
3732
|
-
const custom = await ctx.ui.input(
|
|
3733
|
-
`${result.agent === "all" ? "all agents" : sanitizeTerminalText(result.agent)} custom model id`,
|
|
3734
|
-
current === "inherit" ? "provider/model" : sanitizeTerminalText(current),
|
|
3735
|
-
);
|
|
3736
|
-
if (custom === undefined) return;
|
|
3737
|
-
const trimmed = custom.trim();
|
|
3738
|
-
if (trimmed.length > 0) {
|
|
3739
|
-
const model = normalizeModelId(trimmed);
|
|
3740
|
-
if (!model) {
|
|
3741
|
-
ctx.ui.notify(
|
|
3742
|
-
"Custom model id must be a single-line provider/model identifier using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.",
|
|
3743
|
-
"warning",
|
|
3744
|
-
);
|
|
3745
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3746
|
-
continue;
|
|
3747
|
-
}
|
|
3748
|
-
if (result.agent === "all") {
|
|
3749
|
-
const next: AgentModelConfig = { ...config };
|
|
3750
|
-
for (const name of modelAssignmentNames(ctx.cwd)) {
|
|
3751
|
-
next[name] = {
|
|
3752
|
-
...(next[name] ?? {}),
|
|
3753
|
-
model,
|
|
3754
|
-
};
|
|
3755
|
-
}
|
|
3756
|
-
config = next;
|
|
3757
|
-
} else {
|
|
3758
|
-
config = {
|
|
3759
|
-
...config,
|
|
3760
|
-
[result.agent]: {
|
|
3761
|
-
...(config[result.agent] ?? {}),
|
|
3762
|
-
model,
|
|
3763
|
-
},
|
|
3764
|
-
};
|
|
3765
|
-
}
|
|
3766
|
-
}
|
|
3767
|
-
result = await showSddModelPanel(ctx, config, profileLabel);
|
|
3768
|
-
}
|
|
3769
|
-
if (result.type !== "save" && result.type !== "save-profile") return;
|
|
3770
|
-
writeModelConfig(ctx.cwd, result.config);
|
|
3771
|
-
const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
|
|
3772
|
-
ctx.ui.notify(
|
|
3773
|
-
[
|
|
3774
|
-
"el Gentleman global model config saved.",
|
|
3775
|
-
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
3776
|
-
`Agents updated: ${applyResult.updated}`,
|
|
3777
|
-
...describeModelConfig(ctx.cwd, result.config),
|
|
3778
|
-
].join("\n"),
|
|
3779
|
-
"info",
|
|
3780
|
-
);
|
|
3781
|
-
if (result.type === "save-profile") updateCurrentProfileFromSavedRouting(ctx, pi);
|
|
3782
|
-
}
|
|
3783
|
-
|
|
3784
|
-
/** The profile `u` in `/gentle:models` writes to, and why it is that one. */
|
|
3785
|
-
interface CurrentProfileTarget {
|
|
3786
|
-
name: string;
|
|
3787
|
-
source: "pinned" | "active";
|
|
3788
|
-
}
|
|
3789
|
-
|
|
3790
|
-
/**
|
|
3791
|
-
* A pinned repository launches with its pinned profile, so that profile is the
|
|
3792
|
-
* one worth updating from here; anywhere else the globally active profile is.
|
|
3793
|
-
* Neither existing yields no target rather than a guess.
|
|
3794
|
-
*/
|
|
3795
|
-
function resolveCurrentProfileTarget(
|
|
3796
|
-
cwd: string,
|
|
3797
|
-
store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())),
|
|
3798
|
-
): CurrentProfileTarget | undefined {
|
|
3799
|
-
const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
|
|
3800
|
-
if (pin) return { name: pin.profile, source: "pinned" };
|
|
3801
|
-
if (store.status !== "valid" || store.file.active === undefined) return undefined;
|
|
3802
|
-
if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined;
|
|
3803
|
-
return { name: store.file.active, source: "active" };
|
|
4600
|
+
const read = readProfilesFileResult(profilesFilePath(gentleAiConfigHome()));
|
|
4601
|
+
const target = read.status === "valid"
|
|
4602
|
+
? resolveCurrentProfileTarget(ctx.cwd, read)
|
|
4603
|
+
: read.status === "missing"
|
|
4604
|
+
? ({ name: "current", source: "active" } as CurrentProfileTarget)
|
|
4605
|
+
: undefined;
|
|
4606
|
+
await openProfilesPanelLoop(ctx, live, target?.name);
|
|
3804
4607
|
}
|
|
3805
4608
|
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
4609
|
+
/** `/gentle:profiles export-routing`: write the saved global routing to models.export.json. */
|
|
4610
|
+
async function exportRoutingCommand(ctx: ExtensionContext): Promise<void> {
|
|
4611
|
+
try {
|
|
4612
|
+
const count = await exportSavedModelConfig(ctx);
|
|
4613
|
+
ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info");
|
|
4614
|
+
} catch (error) {
|
|
4615
|
+
ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
4616
|
+
}
|
|
3809
4617
|
}
|
|
3810
4618
|
|
|
3811
|
-
/**
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
*/
|
|
3817
|
-
function updateCurrentProfileFromSavedRouting(ctx: ExtensionContext, pi: ExtensionAPI): void {
|
|
3818
|
-
const path = profilesFilePath(gentleAiConfigHome());
|
|
3819
|
-
const read = readProfilesFileResult(path);
|
|
3820
|
-
if (read.status === "invalid") {
|
|
3821
|
-
ctx.ui.notify(
|
|
3822
|
-
`el Gentleman saved the global routing, but cannot update a profile because ${sanitizeTerminalText(path)} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
|
|
3823
|
-
"warning",
|
|
3824
|
-
);
|
|
4619
|
+
/** `/gentle:profiles restore-routing`: replace the saved global routing with models.export.json, then apply it. */
|
|
4620
|
+
async function restoreModelExport(ctx: ExtensionContext): Promise<void> {
|
|
4621
|
+
const restored = await readModelExport(ctx);
|
|
4622
|
+
if (!restored) {
|
|
4623
|
+
ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning");
|
|
3825
4624
|
return;
|
|
3826
4625
|
}
|
|
3827
|
-
const
|
|
3828
|
-
|
|
3829
|
-
|
|
4626
|
+
const approved = await ctx.ui.confirm(
|
|
4627
|
+
"Restore saved model routing?",
|
|
4628
|
+
`Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`,
|
|
3830
4629
|
);
|
|
3831
|
-
if (
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
};
|
|
3836
|
-
}
|
|
3837
|
-
if (read.status === "missing") {
|
|
3838
|
-
// The same seed `/gentle:profiles` performs on its first open, so pressing
|
|
3839
|
-
// `u` before ever opening that panel lands on the same "current" profile.
|
|
3840
|
-
try {
|
|
3841
|
-
writeProfilesFileSync(path, bootstrapProfilesFile(snapshot));
|
|
3842
|
-
} catch (error) {
|
|
3843
|
-
ctx.ui.notify(
|
|
3844
|
-
`el Gentleman saved the global routing, but could not create ${sanitizeTerminalText(path)}: ${profilesErrorMessage(error)}`,
|
|
3845
|
-
"warning",
|
|
3846
|
-
);
|
|
3847
|
-
return;
|
|
3848
|
-
}
|
|
3849
|
-
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${sanitizeTerminalText(path)} from the routing just saved.`, "info");
|
|
3850
|
-
return;
|
|
3851
|
-
}
|
|
3852
|
-
reportProfilesDrops(ctx, path, read.drops);
|
|
3853
|
-
const target = resolveCurrentProfileTarget(ctx.cwd, read);
|
|
3854
|
-
if (!target) {
|
|
3855
|
-
ctx.ui.notify(
|
|
3856
|
-
"el Gentleman saved the global routing, but no profile is current: none is active and this repository pins none. Apply or create one with /gentle:profiles, then press u here or s there.",
|
|
3857
|
-
"warning",
|
|
3858
|
-
);
|
|
4630
|
+
if (!approved) return;
|
|
4631
|
+
try {
|
|
4632
|
+
await writeModelConfigAsync(ctx.cwd, restored);
|
|
4633
|
+
} catch (error) {
|
|
4634
|
+
ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
3859
4635
|
return;
|
|
3860
4636
|
}
|
|
3861
4637
|
try {
|
|
3862
|
-
|
|
4638
|
+
const applyResult = await applyModelConfigAsync(ctx.cwd, restored);
|
|
4639
|
+
ctx.ui.notify([
|
|
4640
|
+
"el Gentleman restored global model config.",
|
|
4641
|
+
`Import: ${modelExportPath(ctx.cwd)}`,
|
|
4642
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
4643
|
+
`Agents updated: ${applyResult.updated}`,
|
|
4644
|
+
].join("\n"), "info");
|
|
3863
4645
|
} catch (error) {
|
|
3864
|
-
ctx.ui.notify(
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
4646
|
+
ctx.ui.notify([
|
|
4647
|
+
"el Gentleman restored global model config, but applying it to agents failed.",
|
|
4648
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
4649
|
+
`Apply error: ${error instanceof Error ? error.message : String(error)}`,
|
|
4650
|
+
].join("\n"), "warning");
|
|
3869
4651
|
}
|
|
3870
|
-
ctx.ui.notify(
|
|
3871
|
-
[
|
|
3872
|
-
`el Gentleman: Profile "${target.name}" updated from the routing just saved (${target.source === "pinned" ? "this repository's pinned profile" : "the active profile"}).`,
|
|
3873
|
-
`Profiles store: ${sanitizeTerminalText(path)}`,
|
|
3874
|
-
].join("\n"),
|
|
3875
|
-
"info",
|
|
3876
|
-
);
|
|
3877
4652
|
}
|
|
3878
4653
|
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
4654
|
+
/** The profile bare `/gentle:profiles` preselects in the unified panel, and why it is that one. */
|
|
4655
|
+
interface CurrentProfileTarget {
|
|
4656
|
+
name: string;
|
|
4657
|
+
source: "pinned" | "active";
|
|
4658
|
+
}
|
|
4659
|
+
|
|
4660
|
+
/**
|
|
4661
|
+
* A pinned repository launches with its pinned profile, so that profile is the
|
|
4662
|
+
* one worth updating from here; anywhere else the globally active profile is.
|
|
4663
|
+
* Neither existing yields no target rather than a guess.
|
|
4664
|
+
*/
|
|
4665
|
+
function resolveCurrentProfileTarget(
|
|
4666
|
+
cwd: string,
|
|
4667
|
+
store: ProfilesFileReadResult = readProfilesFileResult(profilesFilePath(gentleAiConfigHome())),
|
|
4668
|
+
): CurrentProfileTarget | undefined {
|
|
4669
|
+
const pin = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
|
|
4670
|
+
if (pin) return { name: pin.profile, source: "pinned" };
|
|
4671
|
+
if (store.status !== "valid" || store.file.active === undefined) return undefined;
|
|
4672
|
+
if (!hasOwnProfile(store.file.profiles, store.file.active)) return undefined;
|
|
4673
|
+
return { name: store.file.active, source: "active" };
|
|
4674
|
+
}
|
|
4675
|
+
|
|
3891
4676
|
|
|
3892
|
-
type ProfilesSnapshotHandler = (name: string) => AgentProfilesFile;
|
|
3893
4677
|
|
|
3894
4678
|
/**
|
|
3895
4679
|
* What a panel action hands the reopened panel: a one-line status for the footer,
|
|
@@ -3901,10 +4685,6 @@ interface ProfilesPanelReport {
|
|
|
3901
4685
|
selectedName?: string;
|
|
3902
4686
|
}
|
|
3903
4687
|
|
|
3904
|
-
const PROFILES_PANEL_MIN_BODY_ROWS = 6;
|
|
3905
|
-
|
|
3906
|
-
type ProfilesPanelPointerLayout = AgentsViewLayout & { listTop: number };
|
|
3907
|
-
|
|
3908
4688
|
function hasOwnProfile(profiles: Record<string, unknown>, name: string): boolean {
|
|
3909
4689
|
return Object.prototype.hasOwnProperty.call(profiles, name);
|
|
3910
4690
|
}
|
|
@@ -3936,8 +4716,8 @@ function profilePinStateLabel(
|
|
|
3936
4716
|
* Every line the panel needs to tell a pinned repository the truth: the winning
|
|
3937
4717
|
* layer and its exact file, an explicit warning for a file that is not a pin named
|
|
3938
4718
|
* by path (kept separate from a pin naming a profile this store no longer defines),
|
|
3939
|
-
* and one sentence that a winning pin outranks the
|
|
3940
|
-
*
|
|
4719
|
+
* and one sentence that a winning pin outranks the globally active profile and every
|
|
4720
|
+
* global routing write.
|
|
3941
4721
|
*/
|
|
3942
4722
|
function profilePinDetailLines(
|
|
3943
4723
|
status: ProfilePinStatus | undefined,
|
|
@@ -3954,7 +4734,7 @@ function profilePinDetailLines(
|
|
|
3954
4734
|
lines.push(` the ${issue.source} pin names "${issue.profile}", which this store does not define; ignoring it (${issue.path}).`);
|
|
3955
4735
|
}
|
|
3956
4736
|
if (evaluation.winner) {
|
|
3957
|
-
lines.push(" This repository's subagent routing comes from the pin; the globally active profile and
|
|
4737
|
+
lines.push(" This repository's subagent routing comes from the pin; the globally active profile and global routing writes do not apply here.");
|
|
3958
4738
|
}
|
|
3959
4739
|
return lines;
|
|
3960
4740
|
}
|
|
@@ -3993,15 +4773,15 @@ function followRenamedPin(
|
|
|
3993
4773
|
|
|
3994
4774
|
/**
|
|
3995
4775
|
* One sentence naming the profile this repository resolves, for the commands whose
|
|
3996
|
-
* writes a pin outranks:
|
|
3997
|
-
*
|
|
4776
|
+
* writes a pin outranks: global routing applied from the unified panel does not
|
|
4777
|
+
* reach a pinned repository's subagents. The launch resolver decides, so the note
|
|
3998
4778
|
* names exactly what a launch would use.
|
|
3999
4779
|
*/
|
|
4000
4780
|
function profilePinScopeNote(cwd: string): string | undefined {
|
|
4001
4781
|
const resolution = resolveProfilePin({ cwd, configHome: gentleAiConfigHome() });
|
|
4002
4782
|
if (!resolution) return undefined;
|
|
4003
4783
|
return sanitizeTerminalText(
|
|
4004
|
-
`This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove
|
|
4784
|
+
`This repository pins profile "${resolution.profile}" (${resolution.source} pin at ${resolution.path}), so its subagent launches resolve that profile instead of the global routing. Change or remove it in /gentle:profiles (profile menu ▾ › Use in this repo), or with /gentle:profiles publish <name> for the committed declaration.`,
|
|
4005
4785
|
);
|
|
4006
4786
|
}
|
|
4007
4787
|
|
|
@@ -4012,10 +4792,10 @@ function profilePinScopeNote(cwd: string): string | undefined {
|
|
|
4012
4792
|
* write that already succeeded must not become an error because a probe about sharing
|
|
4013
4793
|
* it failed.
|
|
4014
4794
|
*/
|
|
4015
|
-
function repoDeclarationSharing(root: string, path: string): string {
|
|
4795
|
+
export function repoDeclarationSharing(root: string, path: string): string {
|
|
4016
4796
|
const relativePath = relative(root, path).split(sep).join("/");
|
|
4017
4797
|
const run = (...arguments_: string[]): string =>
|
|
4018
|
-
execFileSync("git", arguments_, { cwd: root, encoding: "utf8" }).trim();
|
|
4798
|
+
execFileSync("git", arguments_, { cwd: root, encoding: "utf8", windowsHide: true }).trim();
|
|
4019
4799
|
try {
|
|
4020
4800
|
// Exit 0 means gitignore matches the path; the exact negation line is what makes
|
|
4021
4801
|
// the declaration committable again.
|
|
@@ -4037,413 +4817,6 @@ function repoDeclarationSharing(root: string, path: string): string {
|
|
|
4037
4817
|
}
|
|
4038
4818
|
}
|
|
4039
4819
|
|
|
4040
|
-
/** Keep a detail-pane scroll offset inside the bounds of its own content. */
|
|
4041
|
-
function clampDetailScroll(offset: number, lineCount: number, bodyRows: number): number {
|
|
4042
|
-
return Math.max(0, Math.min(offset, Math.max(0, lineCount - bodyRows)));
|
|
4043
|
-
}
|
|
4044
|
-
|
|
4045
|
-
// Left profile list, right detail panes. Rendering and pointer routing share
|
|
4046
|
-
// one measured layout; the list rows keep native keyboard, hover, press,
|
|
4047
|
-
// click, and wheel handling through NativeChoiceList. The frame takes the full
|
|
4048
|
-
// overlay, so its height follows the terminal rows the same way AgentsView does.
|
|
4049
|
-
class ProfilesPanel implements OverlayComponent {
|
|
4050
|
-
private completed = false;
|
|
4051
|
-
private pointerLayout: ProfilesPanelPointerLayout | undefined;
|
|
4052
|
-
private detailScroll = 0;
|
|
4053
|
-
private lastDetailLineCount = 0;
|
|
4054
|
-
private lastDetailRows = 0;
|
|
4055
|
-
private lastSelectedId: string | undefined;
|
|
4056
|
-
private feedback: string | undefined;
|
|
4057
|
-
readonly list: NativeChoiceList<ProfileListItem>;
|
|
4058
|
-
private file: AgentProfilesFile;
|
|
4059
|
-
private readonly currentConfig: AgentModelConfig;
|
|
4060
|
-
private readonly done: (result: ProfilesPanelResult) => void;
|
|
4061
|
-
private readonly saveSnapshot: ProfilesSnapshotHandler;
|
|
4062
|
-
private readonly requestRender: () => void;
|
|
4063
|
-
private readonly listItems: ProfileListItem[];
|
|
4064
|
-
private readonly theme: Theme | undefined;
|
|
4065
|
-
private readonly rows: () => number;
|
|
4066
|
-
private readonly orchestratorSettings: OrchestratorSettingsReadResult;
|
|
4067
|
-
// Actions reopen the panel, refreshing this snapshot without disk reads during rendering.
|
|
4068
|
-
private readonly pinStatus: ProfilePinStatus | undefined;
|
|
4069
|
-
private readonly sessionBoundName: string | undefined;
|
|
4070
|
-
private readonly liveOrchestrator: (() => AgentRoutingEntry | undefined) | undefined;
|
|
4071
|
-
|
|
4072
|
-
constructor(
|
|
4073
|
-
file: AgentProfilesFile,
|
|
4074
|
-
currentConfig: AgentModelConfig,
|
|
4075
|
-
done: (result: ProfilesPanelResult) => void,
|
|
4076
|
-
keybindings: KeybindingsManager | undefined,
|
|
4077
|
-
theme: Theme | undefined,
|
|
4078
|
-
selectedName: string | undefined,
|
|
4079
|
-
rows: () => number,
|
|
4080
|
-
orchestratorSettings: OrchestratorSettingsReadResult,
|
|
4081
|
-
saveSnapshot: ProfilesSnapshotHandler,
|
|
4082
|
-
requestRender: () => void,
|
|
4083
|
-
pinStatus: () => ProfilePinStatus | undefined,
|
|
4084
|
-
sessionBound: () => string | undefined,
|
|
4085
|
-
liveOrchestrator?: () => AgentRoutingEntry | undefined,
|
|
4086
|
-
feedback?: string,
|
|
4087
|
-
) {
|
|
4088
|
-
this.file = file;
|
|
4089
|
-
this.feedback = feedback;
|
|
4090
|
-
this.currentConfig = currentConfig;
|
|
4091
|
-
this.done = done;
|
|
4092
|
-
this.saveSnapshot = saveSnapshot;
|
|
4093
|
-
this.requestRender = requestRender;
|
|
4094
|
-
this.theme = theme;
|
|
4095
|
-
this.rows = rows;
|
|
4096
|
-
this.orchestratorSettings = orchestratorSettings;
|
|
4097
|
-
this.pinStatus = pinStatus();
|
|
4098
|
-
this.sessionBoundName = sessionBound();
|
|
4099
|
-
this.liveOrchestrator = liveOrchestrator;
|
|
4100
|
-
const items = buildProfileListItems(file, evaluateProfilePin(this.pinStatus, file.profiles).winner?.profile, this.sessionBoundName);
|
|
4101
|
-
this.listItems = items;
|
|
4102
|
-
this.list = new NativeChoiceList<ProfileListItem>(
|
|
4103
|
-
items,
|
|
4104
|
-
{
|
|
4105
|
-
selectedPrefix: (text) => this.renderText(text, "accent"),
|
|
4106
|
-
selectedText: (text) => this.renderText(text, "accent"),
|
|
4107
|
-
description: (text) => this.renderText(text, "muted"),
|
|
4108
|
-
hoverBackground: (text) => (this.theme ? this.theme.bg("toolPendingBg", text) : text),
|
|
4109
|
-
},
|
|
4110
|
-
keybindings,
|
|
4111
|
-
);
|
|
4112
|
-
this.list.onSelect = (item) => this.finish({ type: "apply", name: item.id });
|
|
4113
|
-
this.list.onCancel = () => this.finish({ type: "close" });
|
|
4114
|
-
const selected = selectedName ? items.findIndex((item) => item.id === selectedName) : -1;
|
|
4115
|
-
if (selected >= 0) this.list.setSelectedIndex(selected);
|
|
4116
|
-
}
|
|
4117
|
-
|
|
4118
|
-
invalidate(): void {
|
|
4119
|
-
this.pointerLayout = undefined;
|
|
4120
|
-
this.list.invalidate();
|
|
4121
|
-
}
|
|
4122
|
-
|
|
4123
|
-
handleInput(data: string): void {
|
|
4124
|
-
if (isKeyRelease(data)) return;
|
|
4125
|
-
if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
|
|
4126
|
-
this.finish({ type: "close" });
|
|
4127
|
-
return;
|
|
4128
|
-
}
|
|
4129
|
-
const name = this.list.getSelectedItem()?.id;
|
|
4130
|
-
if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("j"))) {
|
|
4131
|
-
this.scrollDetail(this.pageRows());
|
|
4132
|
-
return;
|
|
4133
|
-
}
|
|
4134
|
-
if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("k"))) {
|
|
4135
|
-
this.scrollDetail(-this.pageRows());
|
|
4136
|
-
return;
|
|
4137
|
-
}
|
|
4138
|
-
// Agents-view line scroll: j/k move the routing one line at a time. The
|
|
4139
|
-
// arrow keys stay with the profile list, exactly as the letters below stay
|
|
4140
|
-
// profile actions.
|
|
4141
|
-
if (data === "j") {
|
|
4142
|
-
this.scrollDetail(1);
|
|
4143
|
-
return;
|
|
4144
|
-
}
|
|
4145
|
-
if (data === "k") {
|
|
4146
|
-
this.scrollDetail(-1);
|
|
4147
|
-
return;
|
|
4148
|
-
}
|
|
4149
|
-
if (data === "c") return this.finish({ type: "create" });
|
|
4150
|
-
if (data === "i") return this.finish({ type: "import" });
|
|
4151
|
-
if (!name) return this.list.handleInput(data);
|
|
4152
|
-
if (data === "s") {
|
|
4153
|
-
try {
|
|
4154
|
-
this.file = this.saveSnapshot(name);
|
|
4155
|
-
this.refreshListItems();
|
|
4156
|
-
this.feedback = `Snapshot saved; live routing unchanged. Profile "${name}" saved from current routing.`;
|
|
4157
|
-
} catch (error) {
|
|
4158
|
-
this.feedback = `Snapshot failed; live routing unchanged. Profile "${name}" was not saved from current routing: ${profilesErrorMessage(error)}`;
|
|
4159
|
-
}
|
|
4160
|
-
this.requestRender();
|
|
4161
|
-
return;
|
|
4162
|
-
}
|
|
4163
|
-
if (data === "a") return this.finish({ type: "apply-global", name });
|
|
4164
|
-
if (data === "d") return this.finish({ type: "duplicate", name });
|
|
4165
|
-
if (data === "r") return this.finish({ type: "rename", name });
|
|
4166
|
-
if (data === "x") return this.finish({ type: "delete", name });
|
|
4167
|
-
if (data === "e") return this.finish({ type: "export", name });
|
|
4168
|
-
if (data === "p") return this.finish({ type: "pin", source: "local", name });
|
|
4169
|
-
if (data === "P") return this.finish({ type: "pin", source: "repo", name });
|
|
4170
|
-
this.list.handleInput(data);
|
|
4171
|
-
}
|
|
4172
|
-
|
|
4173
|
-
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
4174
|
-
const layout = this.pointerLayout;
|
|
4175
|
-
if (!layout) return undefined;
|
|
4176
|
-
const inBody = event.y >= layout.listTop && event.y < layout.listTop + layout.bodyRows;
|
|
4177
|
-
if (
|
|
4178
|
-
inBody &&
|
|
4179
|
-
event.type === "wheel" &&
|
|
4180
|
-
event.wheelDelta &&
|
|
4181
|
-
layout.mode === "panes" &&
|
|
4182
|
-
event.x >= layout.threadX &&
|
|
4183
|
-
event.x < layout.threadX + layout.threadWidth
|
|
4184
|
-
) {
|
|
4185
|
-
this.scrollDetail(event.wheelDelta);
|
|
4186
|
-
return { handled: true, render: true };
|
|
4187
|
-
}
|
|
4188
|
-
if (!inBody) return undefined;
|
|
4189
|
-
if (event.x < layout.listX || event.x >= layout.listX + layout.listWidth) return undefined;
|
|
4190
|
-
return this.list.handleMouse({
|
|
4191
|
-
...event,
|
|
4192
|
-
x: event.x - layout.listX,
|
|
4193
|
-
y: event.y - layout.listTop,
|
|
4194
|
-
width: layout.listWidth,
|
|
4195
|
-
height: layout.bodyRows,
|
|
4196
|
-
});
|
|
4197
|
-
}
|
|
4198
|
-
|
|
4199
|
-
render(width: number): string[] {
|
|
4200
|
-
const safeWidth = Math.max(1, width);
|
|
4201
|
-
const listWidth = measureAgentsViewLayout(safeWidth, PROFILES_PANEL_MIN_BODY_ROWS + 3).listWidth;
|
|
4202
|
-
const listLines = this.list.render(listWidth);
|
|
4203
|
-
// The frame fills the overlay, and the overlay fills the terminal, so the
|
|
4204
|
-
// body height comes from the terminal rows rather than from the content.
|
|
4205
|
-
const rows = Math.max(PROFILES_PANEL_MIN_BODY_ROWS + 3, Math.floor(this.rows()));
|
|
4206
|
-
const layout = measureAgentsViewLayout(safeWidth, rows);
|
|
4207
|
-
if (layout.mode === "fallback" || layout.width === 0) {
|
|
4208
|
-
this.pointerLayout = undefined;
|
|
4209
|
-
return [];
|
|
4210
|
-
}
|
|
4211
|
-
const selectedId = this.list.getSelectedItem()?.id;
|
|
4212
|
-
if (selectedId !== this.lastSelectedId) {
|
|
4213
|
-
this.lastSelectedId = selectedId;
|
|
4214
|
-
this.detailScroll = 0;
|
|
4215
|
-
}
|
|
4216
|
-
const detailLines = this.renderDetailLines(layout.threadWidth, layout.mode === "panes");
|
|
4217
|
-
this.lastDetailLineCount = detailLines.length;
|
|
4218
|
-
this.lastDetailRows = layout.bodyRows;
|
|
4219
|
-
this.detailScroll = clampDetailScroll(this.detailScroll, detailLines.length, layout.bodyRows);
|
|
4220
|
-
this.pointerLayout = { ...layout, listTop: 1 };
|
|
4221
|
-
const rule = "─".repeat(Math.max(0, layout.width - 2));
|
|
4222
|
-
const lines: string[] = [this.renderText(`╭${rule}╮`, "border")];
|
|
4223
|
-
for (let row = 0; row < layout.bodyRows; row += 1) {
|
|
4224
|
-
lines.push(this.renderBodyRow(row, layout, listLines, detailLines));
|
|
4225
|
-
}
|
|
4226
|
-
lines.push(this.renderFooterRow(layout.width));
|
|
4227
|
-
lines.push(this.renderText(`╰${rule}╯`, "border"));
|
|
4228
|
-
return lines;
|
|
4229
|
-
}
|
|
4230
|
-
|
|
4231
|
-
private scrollDetail(delta: number): void {
|
|
4232
|
-
this.detailScroll = clampDetailScroll(
|
|
4233
|
-
this.detailScroll + Math.trunc(delta),
|
|
4234
|
-
this.lastDetailLineCount,
|
|
4235
|
-
this.lastDetailRows,
|
|
4236
|
-
);
|
|
4237
|
-
}
|
|
4238
|
-
|
|
4239
|
-
private refreshListItems(): void {
|
|
4240
|
-
// Keep the list instance (and its pointer observer) alive while refreshing the
|
|
4241
|
-
// mutable item records that NativeChoiceList already holds by reference.
|
|
4242
|
-
for (const item of buildProfileListItems(this.file, evaluateProfilePin(this.pinStatus, this.file.profiles).winner?.profile, this.sessionBoundName)) {
|
|
4243
|
-
const current = this.listItems.find((candidate) => candidate.id === item.id);
|
|
4244
|
-
if (current) Object.assign(current, item);
|
|
4245
|
-
}
|
|
4246
|
-
this.list.refreshItems();
|
|
4247
|
-
}
|
|
4248
|
-
|
|
4249
|
-
private pageRows(): number {
|
|
4250
|
-
return Math.max(1, this.lastDetailRows - 1);
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
|
-
private finish(result: ProfilesPanelResult): void {
|
|
4254
|
-
if (this.completed) return;
|
|
4255
|
-
this.completed = true;
|
|
4256
|
-
this.list.setDisabled(true);
|
|
4257
|
-
this.done(result);
|
|
4258
|
-
}
|
|
4259
|
-
|
|
4260
|
-
private renderBodyRow(
|
|
4261
|
-
row: number,
|
|
4262
|
-
layout: AgentsViewLayout,
|
|
4263
|
-
listLines: string[],
|
|
4264
|
-
detailLines: string[],
|
|
4265
|
-
): string {
|
|
4266
|
-
if (layout.mode === "panes") {
|
|
4267
|
-
return [
|
|
4268
|
-
this.renderText("│", "border"),
|
|
4269
|
-
" ",
|
|
4270
|
-
this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
|
|
4271
|
-
" ",
|
|
4272
|
-
this.renderText("│", "border"),
|
|
4273
|
-
" ",
|
|
4274
|
-
this.fitPaneLine(detailLines[this.detailScroll + row] ?? "", layout.threadWidth),
|
|
4275
|
-
this.renderText("│", "border"),
|
|
4276
|
-
].join("");
|
|
4277
|
-
}
|
|
4278
|
-
return [
|
|
4279
|
-
this.renderText("│", "border"),
|
|
4280
|
-
" ",
|
|
4281
|
-
this.fitPaneLine(listLines[row] ?? "", layout.listWidth),
|
|
4282
|
-
" ",
|
|
4283
|
-
this.renderText("│", "border"),
|
|
4284
|
-
].join("");
|
|
4285
|
-
}
|
|
4286
|
-
|
|
4287
|
-
private renderFooterRow(width: number): string {
|
|
4288
|
-
const hints =
|
|
4289
|
-
"enter use in this session · a set as global default · c create · s snapshot · d duplicate · r rename · x delete · e export · i import · p pin · P share · j/k line · ctrl+j/k page · esc close";
|
|
4290
|
-
const text = this.feedback ?? hints;
|
|
4291
|
-
return [
|
|
4292
|
-
this.renderText("│", "border"),
|
|
4293
|
-
" ",
|
|
4294
|
-
this.fitPaneLine(this.renderText(text, this.feedback ? "status" : "muted"), width - 4),
|
|
4295
|
-
" ",
|
|
4296
|
-
this.renderText("│", "border"),
|
|
4297
|
-
].join("");
|
|
4298
|
-
}
|
|
4299
|
-
|
|
4300
|
-
private renderDetailLines(width: number, showDetail: boolean): string[] {
|
|
4301
|
-
if (!showDetail) return [];
|
|
4302
|
-
const name = this.list.getSelectedItem()?.id;
|
|
4303
|
-
if (!name || !hasOwnProfile(this.file.profiles, name)) {
|
|
4304
|
-
return [this.renderLine("No profile selected.", width, "muted")];
|
|
4305
|
-
}
|
|
4306
|
-
const config = this.file.profiles[name];
|
|
4307
|
-
const profileRows = profileRoutingRows(config);
|
|
4308
|
-
const currentRows = profileRoutingRows(this.currentConfig);
|
|
4309
|
-
// One shared measurement across both tables, so the same agent sits in the
|
|
4310
|
-
// same column whether it comes from the profile or from models.json.
|
|
4311
|
-
const widths = routingColumnWidths(profileRows, currentRows);
|
|
4312
|
-
return [
|
|
4313
|
-
this.renderLine(name === this.file.active ? `${name} (active)` : name, width, "title"),
|
|
4314
|
-
this.renderLine(
|
|
4315
|
-
`orchestrator ${formatOrchestratorSelection(readProfileOrchestrator(config))}`,
|
|
4316
|
-
width,
|
|
4317
|
-
"text",
|
|
4318
|
-
),
|
|
4319
|
-
this.renderLine(`now ${this.effectiveOrchestratorLabel()}`, width, "muted"),
|
|
4320
|
-
// A pin only redirects subagent routing, so it is reported next to the
|
|
4321
|
-
// orchestrator lines it deliberately does not move. The winning layer's path,
|
|
4322
|
-
// any invalid or stale layer, and the scope sentence all come from the shared
|
|
4323
|
-
// precedence rule the launch resolver uses.
|
|
4324
|
-
...profilePinDetailLines(this.pinStatus, this.file.profiles).map((line) => this.renderLine(line, width, "muted")),
|
|
4325
|
-
// gentle-shell#1064: the binding is stored for this session and outranks
|
|
4326
|
-
// the pin in the panel list, so it is named right after the pin layers.
|
|
4327
|
-
// Since #1558 launches resolve it ahead of pins and the global default,
|
|
4328
|
-
// and nothing was written to make that true.
|
|
4329
|
-
...(this.sessionBoundName === undefined
|
|
4330
|
-
? []
|
|
4331
|
-
: [this.renderLine(`session ${sanitizeTerminalText(this.sessionBoundName)} (session) — launches resolve it ahead of pins and the global default; nothing was written`, width, "muted")]),
|
|
4332
|
-
"",
|
|
4333
|
-
this.renderLine("Profile routing", width, "accent"),
|
|
4334
|
-
...this.indentLines(this.routingLines(profileRows, widths), width),
|
|
4335
|
-
"",
|
|
4336
|
-
this.renderLine("Current routing (effective)", width, "accent"),
|
|
4337
|
-
...this.indentLines(this.routingLines(currentRows, widths), width),
|
|
4338
|
-
];
|
|
4339
|
-
}
|
|
4340
|
-
|
|
4341
|
-
private effectiveOrchestratorLabel(): string {
|
|
4342
|
-
// The now line answers what this session runs: the live orchestrator wins
|
|
4343
|
-
// when one exists, and the settings read only stands in for sessions
|
|
4344
|
-
// without a live model (and still surfaces an unreadable settings file).
|
|
4345
|
-
const live = this.liveOrchestrator?.();
|
|
4346
|
-
if (live !== undefined) {
|
|
4347
|
-
return formatOrchestratorSelection(live);
|
|
4348
|
-
}
|
|
4349
|
-
const settings = this.orchestratorSettings;
|
|
4350
|
-
if (settings.status === "invalid") {
|
|
4351
|
-
return `unreadable (${sanitizeTerminalText(settings.reason)})`;
|
|
4352
|
-
}
|
|
4353
|
-
return formatOrchestratorSelection(settings.status === "valid" ? settings.entry : undefined);
|
|
4354
|
-
}
|
|
4355
|
-
|
|
4356
|
-
private routingLines(
|
|
4357
|
-
rows: ProfileRoutingRow[],
|
|
4358
|
-
widths: { agent: number; model: number },
|
|
4359
|
-
): string[] {
|
|
4360
|
-
if (rows.length === 0) {
|
|
4361
|
-
return ["No routing entries — every agent inherits its default model."];
|
|
4362
|
-
}
|
|
4363
|
-
return rows.map((row) => formatRoutingRow(row, widths));
|
|
4364
|
-
}
|
|
4365
|
-
|
|
4366
|
-
private indentLines(lines: string[], width: number): string[] {
|
|
4367
|
-
return lines.map((line) => this.renderLine(` ${line}`, width, "text"));
|
|
4368
|
-
}
|
|
4369
|
-
|
|
4370
|
-
private fitPaneLine(line: string, width: number): string {
|
|
4371
|
-
const visible = stripAnsi(line);
|
|
4372
|
-
if (visible.length > width) {
|
|
4373
|
-
return truncateToWidth(visible, Math.max(1, width), "…", true);
|
|
4374
|
-
}
|
|
4375
|
-
return `${line}${" ".repeat(Math.max(0, width - visible.length))}`;
|
|
4376
|
-
}
|
|
4377
|
-
|
|
4378
|
-
private renderLine(text: string, width: number, tone?: PanelTone): string {
|
|
4379
|
-
const safe = truncateToWidth(
|
|
4380
|
-
sanitizeTerminalText(text),
|
|
4381
|
-
Math.max(1, width),
|
|
4382
|
-
"…",
|
|
4383
|
-
true,
|
|
4384
|
-
);
|
|
4385
|
-
return tone ? this.renderText(safe, tone) : safe;
|
|
4386
|
-
}
|
|
4387
|
-
|
|
4388
|
-
private renderText(text: string, tone: PanelTone): string {
|
|
4389
|
-
const safe = sanitizeTerminalText(text);
|
|
4390
|
-
if (!this.theme) return safe;
|
|
4391
|
-
return this.theme.fg(PANEL_TONE_COLOR[tone], safe);
|
|
4392
|
-
}
|
|
4393
|
-
}
|
|
4394
|
-
|
|
4395
|
-
/** The full-screen profile picker: one visit per action, so every reopen reads fresh store, pin, orchestrator, and session-binding state. */
|
|
4396
|
-
async function showProfilesPanel(
|
|
4397
|
-
ctx: ExtensionContext,
|
|
4398
|
-
file: AgentProfilesFile,
|
|
4399
|
-
currentConfig: AgentModelConfig,
|
|
4400
|
-
selectedName: string | undefined,
|
|
4401
|
-
saveSnapshot: ProfilesSnapshotHandler,
|
|
4402
|
-
live: LiveSession,
|
|
4403
|
-
status?: string,
|
|
4404
|
-
sessionBoundName?: string,
|
|
4405
|
-
): Promise<ProfilesPanelResult> {
|
|
4406
|
-
// Both orchestrator and pin state are snapshots for this panel visit.
|
|
4407
|
-
// Actions (including p/P) reopen the panel and read fresh state.
|
|
4408
|
-
const orchestratorSettings = readOrchestratorSettings(orchestratorSettingsPath());
|
|
4409
|
-
return ctx.ui.custom<ProfilesPanelResult>(
|
|
4410
|
-
(tui, theme, keybindings, done) => {
|
|
4411
|
-
const panel = new ProfilesPanel(
|
|
4412
|
-
file,
|
|
4413
|
-
currentConfig,
|
|
4414
|
-
done,
|
|
4415
|
-
keybindings,
|
|
4416
|
-
theme,
|
|
4417
|
-
selectedName,
|
|
4418
|
-
() => Math.max(0, tui.terminal.rows),
|
|
4419
|
-
orchestratorSettings,
|
|
4420
|
-
saveSnapshot,
|
|
4421
|
-
() => tui.requestRender(),
|
|
4422
|
-
() => readProfilePinStatus(ctx.cwd),
|
|
4423
|
-
() => sessionBoundName,
|
|
4424
|
-
() => liveOrchestratorSnapshot(ctx, live)?.entry,
|
|
4425
|
-
status,
|
|
4426
|
-
);
|
|
4427
|
-
const container = createNativeFullscreenInteraction({
|
|
4428
|
-
keyboardTarget: panel,
|
|
4429
|
-
requestRender: () => tui.requestRender(),
|
|
4430
|
-
mouseObserver: panel.list.createMouseObserver(() => tui.requestRender()),
|
|
4431
|
-
});
|
|
4432
|
-
container.addChild(panel);
|
|
4433
|
-
return container;
|
|
4434
|
-
},
|
|
4435
|
-
{
|
|
4436
|
-
overlay: true,
|
|
4437
|
-
overlayOptions: {
|
|
4438
|
-
anchor: "center",
|
|
4439
|
-
width: "100%",
|
|
4440
|
-
maxHeight: "100%",
|
|
4441
|
-
margin: 0,
|
|
4442
|
-
},
|
|
4443
|
-
},
|
|
4444
|
-
);
|
|
4445
|
-
}
|
|
4446
|
-
|
|
4447
4820
|
function reportProfilesDrops(ctx: ExtensionContext, path: string, drops: ProfilesParseDrops): void {
|
|
4448
4821
|
// Dropped names are by definition the ones that failed validation, so they are
|
|
4449
4822
|
// untrusted input reaching the terminal and must be sanitized like any other
|
|
@@ -4538,35 +4911,43 @@ function profileSnapshotFrom(
|
|
|
4538
4911
|
return snapshot;
|
|
4539
4912
|
}
|
|
4540
4913
|
|
|
4914
|
+
/** The store actions runProfilesPanelAction implements; the loop handles the rest itself. */
|
|
4915
|
+
type ProfileStoreAction = Extract<
|
|
4916
|
+
RoutingTableResult,
|
|
4917
|
+
{ type: "use-session" | "apply-global" | "create" | "duplicate" | "rename" | "delete" | "export-profile" | "import-profile" | "pin" }
|
|
4918
|
+
>;
|
|
4919
|
+
|
|
4541
4920
|
/** Runs one finished panel action against the store and the live session, returning the file the reopened panel should show. */
|
|
4542
4921
|
async function runProfilesPanelAction(
|
|
4543
4922
|
ctx: ExtensionContext,
|
|
4544
4923
|
live: LiveSession,
|
|
4545
4924
|
path: string,
|
|
4546
4925
|
file: AgentProfilesFile,
|
|
4547
|
-
result:
|
|
4926
|
+
result: ProfileStoreAction,
|
|
4548
4927
|
report: ProfilesPanelReport = {},
|
|
4549
4928
|
): Promise<AgentProfilesFile> {
|
|
4550
4929
|
switch (result.type) {
|
|
4551
|
-
case "
|
|
4930
|
+
case "use-session": {
|
|
4552
4931
|
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
4553
|
-
//
|
|
4554
|
-
// switches only its live orchestrator. No shared defaults,
|
|
4555
|
-
// stores, agent frontmatter, pins, or declarations are written.
|
|
4932
|
+
// Use in this session binds a complete routing snapshot to this parent
|
|
4933
|
+
// session and switches only its live orchestrator. No shared defaults,
|
|
4934
|
+
// materialized stores, agent frontmatter, pins, or declarations are written.
|
|
4556
4935
|
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
4557
4936
|
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
4937
|
+
report.status = `This Pi session has no id to bind "${result.name}" to. Use Make default instead.`;
|
|
4558
4938
|
ctx.ui.notify(
|
|
4559
|
-
`el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here.
|
|
4939
|
+
`el Gentleman cannot bind profile "${result.name}" to this session: no parent session id is available here. Use Make default instead.`,
|
|
4560
4940
|
"warning",
|
|
4561
4941
|
);
|
|
4562
4942
|
return file;
|
|
4563
4943
|
}
|
|
4564
|
-
const snapshot = normalizeModelConfig(file.profiles[result.name]) ?? {};
|
|
4944
|
+
const snapshot = normalizeModelConfig(result.config ?? file.profiles[result.name]) ?? {};
|
|
4565
4945
|
bindSessionProfile(sessionId, result.name, snapshot);
|
|
4566
4946
|
const orchestrator = readProfileOrchestrator(snapshot);
|
|
4567
4947
|
const liveNote = orchestrator === undefined ? "" : await switchLiveOrchestrator(ctx, live, orchestrator);
|
|
4948
|
+
report.status = `This session now uses "${result.name}". Nothing shared was written.${liveNote.replace(/\n/g, " ")}`;
|
|
4568
4949
|
ctx.ui.notify(
|
|
4569
|
-
`el Gentleman bound profile "${result.name}" to this session —
|
|
4950
|
+
`el Gentleman bound profile "${result.name}" to this session — its tab shows ◉. Subagents and reviewers use this session's routing snapshot. Shared defaults were not written: the global routing, pins, and materialized stores are untouched. Make default writes them.${liveNote}`,
|
|
4570
4951
|
"info",
|
|
4571
4952
|
);
|
|
4572
4953
|
return file;
|
|
@@ -4598,7 +4979,7 @@ async function runProfilesPanelAction(
|
|
|
4598
4979
|
ctx.ui.notify(pinNote, "info");
|
|
4599
4980
|
return file;
|
|
4600
4981
|
}
|
|
4601
|
-
const normalized =
|
|
4982
|
+
const normalized = cloneModelConfig(result.config);
|
|
4602
4983
|
const orchestratorEntry = readProfileOrchestrator(normalized);
|
|
4603
4984
|
const hasAgentRoutes = Object.keys(normalized).some((name) => !isProfileOrchestratorKey(name));
|
|
4604
4985
|
// Every global apply confirms before anything is written: applying replaces
|
|
@@ -4874,28 +5255,6 @@ async function runProfilesPanelAction(
|
|
|
4874
5255
|
return file;
|
|
4875
5256
|
}
|
|
4876
5257
|
}
|
|
4877
|
-
case "update": {
|
|
4878
|
-
// A profile is a complete snapshot, so capturing the current routing also
|
|
4879
|
-
// captures the orchestrator the routing is running under. A settings file
|
|
4880
|
-
// that cannot be read leaves the snapshot without an orchestrator entry
|
|
4881
|
-
// rather than inventing one.
|
|
4882
|
-
const snapshot = profileSnapshotFrom(
|
|
4883
|
-
await readEffectiveModelConfigAsync(ctx.cwd),
|
|
4884
|
-
readOrchestratorSettings(orchestratorSettingsPath()),
|
|
4885
|
-
);
|
|
4886
|
-
try {
|
|
4887
|
-
const next = updateProfile(file, result.name, snapshot);
|
|
4888
|
-
writeProfilesFileSync(path, next);
|
|
4889
|
-
ctx.ui.notify(
|
|
4890
|
-
`el Gentleman updated profile "${result.name}" from the current routing in ${modelConfigPath(ctx.cwd)}.`,
|
|
4891
|
-
"info",
|
|
4892
|
-
);
|
|
4893
|
-
return next;
|
|
4894
|
-
} catch (error) {
|
|
4895
|
-
ctx.ui.notify(`Profile not updated: ${profilesErrorMessage(error)}`, "warning");
|
|
4896
|
-
return file;
|
|
4897
|
-
}
|
|
4898
|
-
}
|
|
4899
5258
|
case "duplicate": {
|
|
4900
5259
|
// The Pi host starts the field empty and ignores the placeholder, so the title
|
|
4901
5260
|
// names the suggestion and Enter on an untouched field accepts it.
|
|
@@ -4939,7 +5298,7 @@ async function runProfilesPanelAction(
|
|
|
4939
5298
|
ctx.ui.notify(
|
|
4940
5299
|
`el Gentleman renamed profile "${result.name}" to "${name}".` +
|
|
4941
5300
|
(follow.followed.length > 0 ? `\nUpdated the clone pin: ${follow.followed.map((entry) => sanitizeTerminalText(entry)).join(", ")}.` : "") +
|
|
4942
|
-
(follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}";
|
|
5301
|
+
(follow.stillDeclared.length > 0 ? `\nThe committed repository declaration ${follow.stillDeclared.map((entry) => sanitizeTerminalText(entry)).join(", ")} still names "${result.name}"; run /gentle:profiles publish ${name} to republish it.` : ""),
|
|
4943
5302
|
"info",
|
|
4944
5303
|
);
|
|
4945
5304
|
report.status = `Profile "${result.name}" renamed to "${name}".`;
|
|
@@ -4971,7 +5330,7 @@ async function runProfilesPanelAction(
|
|
|
4971
5330
|
}
|
|
4972
5331
|
if (namedLayers.length > 0) {
|
|
4973
5332
|
ctx.ui.notify(
|
|
4974
|
-
`Profile "${result.name}" is pinned for this repository (${namedLayers.join(" and ")}). Remove the pin first
|
|
5333
|
+
`Profile "${result.name}" is pinned for this repository (${namedLayers.join(" and ")}). Remove the pin first (profile menu ▾ › Stop using in this repo for the clone pin, /gentle:profiles publish <name> for the declaration), then delete it.`,
|
|
4975
5334
|
"warning",
|
|
4976
5335
|
);
|
|
4977
5336
|
return file;
|
|
@@ -5000,6 +5359,7 @@ async function runProfilesPanelAction(
|
|
|
5000
5359
|
// failure here has nothing to roll back.
|
|
5001
5360
|
const status = readProfilePinStatus(ctx.cwd);
|
|
5002
5361
|
if (!status) {
|
|
5362
|
+
report.status = "This session is not inside a Git worktree, so nothing was pinned.";
|
|
5003
5363
|
ctx.ui.notify(
|
|
5004
5364
|
"el Gentleman cannot pin a profile because this session is not inside a Git worktree. Pinning is per repository; apply the profile globally instead.",
|
|
5005
5365
|
"warning",
|
|
@@ -5014,6 +5374,7 @@ async function runProfilesPanelAction(
|
|
|
5014
5374
|
if (currentlyPinned) clearProfilePinSync(pinPath);
|
|
5015
5375
|
else writeProfilePinSync(pinPath, result.name);
|
|
5016
5376
|
} catch (error) {
|
|
5377
|
+
report.status = `The pin could not be updated: ${profilesErrorMessage(error)}`;
|
|
5017
5378
|
ctx.ui.notify(
|
|
5018
5379
|
`el Gentleman could not update profile pin ${sanitizeTerminalText(pinPath)}: ${profilesErrorMessage(error)}`,
|
|
5019
5380
|
"warning",
|
|
@@ -5039,17 +5400,17 @@ async function runProfilesPanelAction(
|
|
|
5039
5400
|
result.source === "local"
|
|
5040
5401
|
? [
|
|
5041
5402
|
`el Gentleman pinned profile "${result.name}" for this clone in ${sanitizeTerminalText(pinPath)}.`,
|
|
5042
|
-
"Subagent launches in this repository resolve that profile at launch; the orchestrator and every other repository keep their global routing.
|
|
5403
|
+
"Subagent launches in this repository resolve that profile at launch; the orchestrator and every other repository keep their global routing. Choose Stop using in this repo in the profile menu (▾) to unpin.",
|
|
5043
5404
|
].join("\n")
|
|
5044
5405
|
: [
|
|
5045
5406
|
`el Gentleman declared profile "${result.name}" for this worktree in ${sanitizeTerminalText(pinPath)}.`,
|
|
5046
|
-
|
|
5407
|
+
`Subagent launches resolve it at launch. Commit the file to share the routing; a local pin takes precedence over it. Run /gentle:profiles publish ${result.name} again to remove the declaration.`,
|
|
5047
5408
|
].join("\n") + sharingNote,
|
|
5048
5409
|
"info",
|
|
5049
5410
|
);
|
|
5050
5411
|
return file;
|
|
5051
5412
|
}
|
|
5052
|
-
case "export": {
|
|
5413
|
+
case "export-profile": {
|
|
5053
5414
|
if (!hasOwnProfile(file.profiles, result.name)) return file;
|
|
5054
5415
|
const exportPath = profileExportPath(gentleAiConfigHome());
|
|
5055
5416
|
try {
|
|
@@ -5062,7 +5423,7 @@ async function runProfilesPanelAction(
|
|
|
5062
5423
|
}
|
|
5063
5424
|
return file;
|
|
5064
5425
|
}
|
|
5065
|
-
case "import": {
|
|
5426
|
+
case "import-profile": {
|
|
5066
5427
|
const importPath = profileExportPath(gentleAiConfigHome());
|
|
5067
5428
|
let text: string;
|
|
5068
5429
|
try {
|
|
@@ -5112,7 +5473,64 @@ async function runProfilesPanelAction(
|
|
|
5112
5473
|
}
|
|
5113
5474
|
|
|
5114
5475
|
/** `/gentle:profiles`: seed or open the store, then loop the panel over one action at a time until it closes. */
|
|
5115
|
-
|
|
5476
|
+
const PROFILES_COMMAND_USAGE = "Usage: /gentle:profiles [export <name> | import | publish <name> | export-routing | restore-routing]";
|
|
5477
|
+
|
|
5478
|
+
/**
|
|
5479
|
+
* `/gentle:profiles` opens the models screen. File operations live in
|
|
5480
|
+
* subcommands rather than on the screen: `export <name>` and `import` move a
|
|
5481
|
+
* profile through `profiles.export.json`, `publish <name>` toggles the
|
|
5482
|
+
* committed repository declaration, and `export-routing` / `restore-routing`
|
|
5483
|
+
* move the saved global routing (models.json, not a profile) through
|
|
5484
|
+
* `models.export.json`. The routing verbs carry their own names so a profile
|
|
5485
|
+
* can never be mistaken for them.
|
|
5486
|
+
*/
|
|
5487
|
+
async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession, args = ""): Promise<void> {
|
|
5488
|
+
const [verb = "", ...rest] = args.trim().split(/\s+/).filter(Boolean);
|
|
5489
|
+
if (verb === "") {
|
|
5490
|
+
await openModelsScreen(ctx, live);
|
|
5491
|
+
return;
|
|
5492
|
+
}
|
|
5493
|
+
if (verb === "export-routing" || verb === "restore-routing") {
|
|
5494
|
+
if (rest.length > 0) {
|
|
5495
|
+
ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning");
|
|
5496
|
+
return;
|
|
5497
|
+
}
|
|
5498
|
+
if (verb === "export-routing") await exportRoutingCommand(ctx);
|
|
5499
|
+
else await restoreModelExport(ctx);
|
|
5500
|
+
return;
|
|
5501
|
+
}
|
|
5502
|
+
const store = await loadProfilesStore(ctx);
|
|
5503
|
+
if (!store) return;
|
|
5504
|
+
const name = rest.join(" ");
|
|
5505
|
+
switch (verb) {
|
|
5506
|
+
case "export":
|
|
5507
|
+
case "publish": {
|
|
5508
|
+
if (name === "" || !hasOwnProfile(store.file.profiles, name)) {
|
|
5509
|
+
ctx.ui.notify(
|
|
5510
|
+
name === "" ? PROFILES_COMMAND_USAGE : `No profile named "${sanitizeTerminalText(name)}". ${PROFILES_COMMAND_USAGE}`,
|
|
5511
|
+
"warning",
|
|
5512
|
+
);
|
|
5513
|
+
return;
|
|
5514
|
+
}
|
|
5515
|
+
await runProfilesPanelAction(
|
|
5516
|
+
ctx,
|
|
5517
|
+
live,
|
|
5518
|
+
store.path,
|
|
5519
|
+
store.file,
|
|
5520
|
+
verb === "export" ? { type: "export-profile", name } : { type: "pin", source: "repo", name },
|
|
5521
|
+
);
|
|
5522
|
+
return;
|
|
5523
|
+
}
|
|
5524
|
+
case "import":
|
|
5525
|
+
await runProfilesPanelAction(ctx, live, store.path, store.file, { type: "import-profile" });
|
|
5526
|
+
return;
|
|
5527
|
+
default:
|
|
5528
|
+
ctx.ui.notify(PROFILES_COMMAND_USAGE, "warning");
|
|
5529
|
+
}
|
|
5530
|
+
}
|
|
5531
|
+
|
|
5532
|
+
/** Reads the profiles store, seeding it on first use; undefined after a reported failure. */
|
|
5533
|
+
async function loadProfilesStore(ctx: ExtensionContext): Promise<{ path: string; file: AgentProfilesFile } | undefined> {
|
|
5116
5534
|
const path = profilesFilePath(gentleAiConfigHome());
|
|
5117
5535
|
const read = readProfilesFileResult(path);
|
|
5118
5536
|
if (read.status === "invalid") {
|
|
@@ -5120,78 +5538,215 @@ async function handleProfilesCommand(ctx: ExtensionContext, live: LiveSession):
|
|
|
5120
5538
|
`el Gentleman cannot open agent profiles because ${path} is invalid JSON or not a profiles file. Fix or remove the file, then run /gentle:profiles again.`,
|
|
5121
5539
|
"warning",
|
|
5122
5540
|
);
|
|
5123
|
-
return;
|
|
5541
|
+
return undefined;
|
|
5542
|
+
}
|
|
5543
|
+
if (read.status === "valid") {
|
|
5544
|
+
reportProfilesDrops(ctx, path, read.drops);
|
|
5545
|
+
return { path, file: read.file };
|
|
5124
5546
|
}
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5547
|
+
const file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd));
|
|
5548
|
+
try {
|
|
5549
|
+
writeProfilesFileSync(path, file);
|
|
5550
|
+
} catch (error) {
|
|
5551
|
+
ctx.ui.notify(`el Gentleman could not create ${path}: ${profilesErrorMessage(error)}`, "warning");
|
|
5552
|
+
return undefined;
|
|
5553
|
+
}
|
|
5554
|
+
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info");
|
|
5555
|
+
return { path, file };
|
|
5556
|
+
}
|
|
5557
|
+
|
|
5558
|
+
/**
|
|
5559
|
+
* The models screen loop: shows one profile's table, runs what the user chose,
|
|
5560
|
+
* and reopens with fresh store, binding, and pin state; a tab switch stays on
|
|
5561
|
+
* the open screen instead. Bare `/gentle:profiles` passes the profile this
|
|
5562
|
+
* repository resolves so it is the tab on screen. Every
|
|
5563
|
+
* outcome is reported on the screen's feedback line, because notifications stay
|
|
5564
|
+
* hidden behind the fullscreen overlay until it closes.
|
|
5565
|
+
*/
|
|
5566
|
+
async function openProfilesPanelLoop(
|
|
5567
|
+
ctx: ExtensionContext,
|
|
5568
|
+
live: LiveSession,
|
|
5569
|
+
initialSelectedName?: string,
|
|
5570
|
+
): Promise<void> {
|
|
5571
|
+
const store = await loadProfilesStore(ctx);
|
|
5572
|
+
if (!store) return;
|
|
5573
|
+
const { path } = store;
|
|
5574
|
+
let file = store.file;
|
|
5575
|
+
const sessionBoundName = () => readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.name;
|
|
5576
|
+
const pinWinner = () => evaluateProfilePin(readProfilePinStatus(ctx.cwd), file.profiles).winner?.profile;
|
|
5577
|
+
let selectedName = initialSelectedName
|
|
5578
|
+
?? resolveCurrentProfileTarget(ctx.cwd, { status: "valid", file, drops: { droppedProfiles: [], droppedAgents: [], droppedActive: undefined } })?.name
|
|
5579
|
+
?? Object.keys(file.profiles)[0];
|
|
5580
|
+
let feedback: string | undefined;
|
|
5581
|
+
let draftOverride: AgentModelConfig | undefined;
|
|
5582
|
+
const writeDraft = (name: string, config: AgentModelConfig): boolean => {
|
|
5128
5583
|
try {
|
|
5129
|
-
|
|
5584
|
+
const next = hasOwnProfile(file.profiles, name)
|
|
5585
|
+
? updateProfile(file, name, cloneModelConfig(config))
|
|
5586
|
+
: createProfile(file, name, cloneModelConfig(config));
|
|
5587
|
+
writeProfilesFileSync(path, next);
|
|
5588
|
+
file = next;
|
|
5589
|
+
return true;
|
|
5130
5590
|
} catch (error) {
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
"warning",
|
|
5134
|
-
);
|
|
5135
|
-
return;
|
|
5591
|
+
feedback = `"${name}" was not saved: ${profilesErrorMessage(error)}`;
|
|
5592
|
+
return false;
|
|
5136
5593
|
}
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5594
|
+
};
|
|
5595
|
+
const switchProfile: RoutingProfileSwitcher = (name, saveFirst) => {
|
|
5596
|
+
const previous = selectedName!;
|
|
5597
|
+
if (saveFirst && !writeDraft(previous, saveFirst)) {
|
|
5598
|
+
const error = feedback ?? `"${previous}" was not saved.`;
|
|
5599
|
+
feedback = undefined;
|
|
5600
|
+
return { error };
|
|
5601
|
+
}
|
|
5602
|
+
selectedName = name;
|
|
5603
|
+
const boundName = sessionBoundName();
|
|
5604
|
+
return {
|
|
5144
5605
|
file,
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
?? readEffectiveModelConfig(ctx.cwd),
|
|
5151
|
-
// The orchestrator follows the same honesty rule: capture what the
|
|
5152
|
-
// session actually runs (ctx.model plus the live thinking level)
|
|
5153
|
-
// when a live model exists; settings.json defaults stand in only
|
|
5154
|
-
// when it does not (LCubero's finding on #1064).
|
|
5155
|
-
liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
|
|
5156
|
-
),
|
|
5157
|
-
);
|
|
5158
|
-
writeProfilesFileSync(path, next);
|
|
5159
|
-
file = next;
|
|
5160
|
-
return next;
|
|
5606
|
+
sessionBoundName: boundName,
|
|
5607
|
+
pinWinner: pinWinner(),
|
|
5608
|
+
notices: routingScreenNotices(ctx, file, name, boundName),
|
|
5609
|
+
feedback: `${saveFirst ? `Saved "${previous}". ` : ""}Showing "${name}".`,
|
|
5610
|
+
};
|
|
5161
5611
|
};
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
file
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
undefined,
|
|
5179
|
-
sessionBoundName(),
|
|
5180
|
-
);
|
|
5181
|
-
while (result.type !== "close") {
|
|
5182
|
-
const report: ProfilesPanelReport = {};
|
|
5183
|
-
file = await runProfilesPanelAction(ctx, live, path, file, result, report);
|
|
5184
|
-
selectedName = report.selectedName ?? ("name" in result ? result.name : undefined);
|
|
5185
|
-
result = await showProfilesPanel(
|
|
5186
|
-
ctx,
|
|
5612
|
+
const differsFromStore = (name: string, config: AgentModelConfig) =>
|
|
5613
|
+
JSON.stringify(normalizeModelConfig(config) ?? {}) !== JSON.stringify(normalizeModelConfig(file.profiles[name]) ?? {});
|
|
5614
|
+
while (true) {
|
|
5615
|
+
if (selectedName === undefined || !hasOwnProfile(file.profiles, selectedName)) {
|
|
5616
|
+
selectedName = Object.keys(file.profiles)[0];
|
|
5617
|
+
draftOverride = undefined;
|
|
5618
|
+
}
|
|
5619
|
+
if (selectedName === undefined) {
|
|
5620
|
+
ctx.ui.notify("el Gentleman has no profiles left. Run /gentle:profiles again to seed one from the routing in effect.", "info");
|
|
5621
|
+
return;
|
|
5622
|
+
}
|
|
5623
|
+
const stored = cloneModelConfig(normalizeModelConfig(file.profiles[selectedName]) ?? {});
|
|
5624
|
+
const boundName = sessionBoundName();
|
|
5625
|
+
const result = await showRoutingTablePanel(ctx, {
|
|
5626
|
+
name: selectedName,
|
|
5627
|
+
config: draftOverride ?? stored,
|
|
5187
5628
|
file,
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
5193
|
-
|
|
5194
|
-
|
|
5629
|
+
sessionBoundName: boundName,
|
|
5630
|
+
pinWinner: pinWinner(),
|
|
5631
|
+
feedback,
|
|
5632
|
+
notices: routingScreenNotices(ctx, file, selectedName, boundName),
|
|
5633
|
+
switchProfile,
|
|
5634
|
+
});
|
|
5635
|
+
draftOverride = undefined;
|
|
5636
|
+
feedback = undefined;
|
|
5637
|
+
const previous = selectedName;
|
|
5638
|
+
if (result.saveFirst && !writeDraft(previous, result.saveFirst)) {
|
|
5639
|
+
draftOverride = result.saveFirst;
|
|
5640
|
+
continue;
|
|
5641
|
+
}
|
|
5642
|
+
const savedNote = result.saveFirst ? `Saved "${previous}". ` : "";
|
|
5643
|
+
switch (result.type) {
|
|
5644
|
+
case "close":
|
|
5645
|
+
return;
|
|
5646
|
+
case "load":
|
|
5647
|
+
selectedName = result.name;
|
|
5648
|
+
feedback = `${savedNote}Showing "${result.name}".`;
|
|
5649
|
+
continue;
|
|
5650
|
+
case "custom": {
|
|
5651
|
+
const label = result.target === PROFILE_ORCHESTRATOR_KEY
|
|
5652
|
+
? "the orchestrator"
|
|
5653
|
+
: result.target === ROUTING_ALL_ROW ? "every agent" : sanitizeTerminalText(result.target);
|
|
5654
|
+
const answer = await ctx.ui.input(`Custom model id for ${label}`, "provider/model");
|
|
5655
|
+
draftOverride = cloneModelConfig(result.config);
|
|
5656
|
+
if (answer === undefined || answer.trim() === "") {
|
|
5657
|
+
feedback = "No custom model set.";
|
|
5658
|
+
continue;
|
|
5659
|
+
}
|
|
5660
|
+
const model = normalizeModelId(answer.trim());
|
|
5661
|
+
if (!model) {
|
|
5662
|
+
feedback = "Custom model id must be one provider/model line using letters, numbers, '.', '-', '_', '~', ':', '@', '/', '+', '%' only.";
|
|
5663
|
+
continue;
|
|
5664
|
+
}
|
|
5665
|
+
const keys = result.target === ROUTING_ALL_ROW ? modelAssignmentNames(ctx.cwd) : [result.target];
|
|
5666
|
+
for (const key of keys) draftOverride[key] = { ...(draftOverride[key] ?? {}), model };
|
|
5667
|
+
feedback = `${label}: model ${model}. Save keeps it in "${previous}".`;
|
|
5668
|
+
continue;
|
|
5669
|
+
}
|
|
5670
|
+
case "save":
|
|
5671
|
+
if (writeDraft(previous, result.config)) feedback = `Saved "${previous}".`;
|
|
5672
|
+
else draftOverride = result.config;
|
|
5673
|
+
continue;
|
|
5674
|
+
case "use-session": {
|
|
5675
|
+
// What you see is what the session uses: edits are saved first.
|
|
5676
|
+
// A tab switch may have changed the shown profile since this visit opened.
|
|
5677
|
+
const config = result.config ?? cloneModelConfig(normalizeModelConfig(file.profiles[previous]) ?? {});
|
|
5678
|
+
const saved = differsFromStore(previous, config);
|
|
5679
|
+
if (saved && !writeDraft(previous, config)) {
|
|
5680
|
+
draftOverride = config;
|
|
5681
|
+
continue;
|
|
5682
|
+
}
|
|
5683
|
+
const report: ProfilesPanelReport = {};
|
|
5684
|
+
file = await runProfilesPanelAction(ctx, live, path, file, { type: "use-session", name: previous, config }, report);
|
|
5685
|
+
feedback = `${saved ? `Saved "${previous}". ` : ""}${report.status ?? ""}`.trim() || undefined;
|
|
5686
|
+
continue;
|
|
5687
|
+
}
|
|
5688
|
+
case "apply-global": {
|
|
5689
|
+
// Make default saves what you see, then runs the guarded global
|
|
5690
|
+
// apply with its diff confirmation, pin honesty, and rollback.
|
|
5691
|
+
const saved = differsFromStore(previous, result.config);
|
|
5692
|
+
if (saved && !writeDraft(previous, result.config)) {
|
|
5693
|
+
draftOverride = result.config;
|
|
5694
|
+
continue;
|
|
5695
|
+
}
|
|
5696
|
+
file = await runProfilesPanelAction(ctx, live, path, file, { type: "apply-global", name: previous, config: cloneModelConfig(result.config) });
|
|
5697
|
+
const prefix = saved ? `Saved "${previous}". ` : "";
|
|
5698
|
+
feedback = file.active === previous
|
|
5699
|
+
? `${prefix}"${previous}" is the default for new sessions.`
|
|
5700
|
+
: pinWinner() === previous
|
|
5701
|
+
? `${prefix}This repo pins its profile, so "${previous}" now applies to this repo only.`
|
|
5702
|
+
: `${prefix}"${previous}" was not made the default.`;
|
|
5703
|
+
continue;
|
|
5704
|
+
}
|
|
5705
|
+
case "create-live": {
|
|
5706
|
+
const answer = await ctx.ui.input("Name for a profile of what's running now", "e.g. snapshot");
|
|
5707
|
+
const name = answer?.trim() ?? "";
|
|
5708
|
+
if (name === "") {
|
|
5709
|
+
feedback = `${savedNote}No profile created.`;
|
|
5710
|
+
continue;
|
|
5711
|
+
}
|
|
5712
|
+
// Captures the routing this session's launches resolve and the
|
|
5713
|
+
// orchestrator it actually runs, the way the old snapshot did.
|
|
5714
|
+
const snapshot = profileSnapshotFrom(
|
|
5715
|
+
readSessionProfileBinding(ctx.sessionManager?.getSessionId?.())?.modelProfiles
|
|
5716
|
+
?? await readEffectiveModelConfigAsync(ctx.cwd),
|
|
5717
|
+
liveOrchestratorSnapshot(ctx, live) ?? readOrchestratorSettings(orchestratorSettingsPath()),
|
|
5718
|
+
);
|
|
5719
|
+
try {
|
|
5720
|
+
const next = createProfile(file, name, snapshot);
|
|
5721
|
+
writeProfilesFileSync(path, next);
|
|
5722
|
+
file = next;
|
|
5723
|
+
selectedName = name;
|
|
5724
|
+
feedback = `${savedNote}Created "${name}" from what's running now.`;
|
|
5725
|
+
} catch (error) {
|
|
5726
|
+
feedback = `${savedNote}Profile not created: ${profilesErrorMessage(error)}`;
|
|
5727
|
+
}
|
|
5728
|
+
continue;
|
|
5729
|
+
}
|
|
5730
|
+
default: {
|
|
5731
|
+
const report: ProfilesPanelReport = {};
|
|
5732
|
+
file = await runProfilesPanelAction(ctx, live, path, file, result, report);
|
|
5733
|
+
selectedName = report.selectedName ?? selectedName;
|
|
5734
|
+
if (result.type === "delete" && !hasOwnProfile(file.profiles, previous)) {
|
|
5735
|
+
feedback = `Deleted "${previous}".`;
|
|
5736
|
+
} else if (result.type === "pin") {
|
|
5737
|
+
feedback = report.status ?? (pinWinner() === previous
|
|
5738
|
+
? `"${previous}" is used in this repo.`
|
|
5739
|
+
: `"${previous}" is no longer pinned to this repo.`);
|
|
5740
|
+
} else {
|
|
5741
|
+
feedback = report.status ? `${savedNote}${report.status}` : savedNote || undefined;
|
|
5742
|
+
}
|
|
5743
|
+
// Rename, pin, and a declined delete keep the profile on screen; carry
|
|
5744
|
+
// unsaved edits across them instead of reloading from the store.
|
|
5745
|
+
if (result.draft && hasOwnProfile(file.profiles, selectedName) && (selectedName === previous || result.type === "rename")) {
|
|
5746
|
+
draftOverride = result.draft;
|
|
5747
|
+
}
|
|
5748
|
+
}
|
|
5749
|
+
}
|
|
5195
5750
|
}
|
|
5196
5751
|
}
|
|
5197
5752
|
|
|
@@ -5293,7 +5848,7 @@ const REVIEW_CONTROLLER_PARAMETER_FIELDS = {
|
|
|
5293
5848
|
},
|
|
5294
5849
|
input: {
|
|
5295
5850
|
type: "string",
|
|
5296
|
-
description: "A JSON object or serialized object string for START/ASSESS
|
|
5851
|
+
description: "A JSON object or serialized object string for START/ASSESS/answer-consent; every other operation requires a serialized object string. New native ordinary START uses {\"mode\":\"ordinary\"}, plus the agent lens selection {\"lenses\":[\"risk\",...],\"lensesReason\":\"<what you touched and how>\"} (both or neither, 1-4 of risk, resilience, readability, reliability; never with focus; providers that predate it keep the tier default); answer-consent uses exactly {\"consentBinding\":\"<opaque id>\",\"answer\":\"granted|declined\"}. Ordinary provider capture belongs only to gentle_review_capture. An explicit baseRef requires committedOnly: true and requests a committed range, while repository-local policyPath remains optional. baseRef must be HEAD, a full 40- or 64-character commit id, or a ref name; abbreviated commit ids are rejected as base-ref-unresolvable. ASSESS accepts an optional object with baseRef, committedOnly, writerModelId, writerEffort, and nativeReviewOutcome (gentle-pi#662/#668/#1175). The writer profile comes from the runtime-recorded model and effort of the pending mutations for this root (writerProfileSource runtime); writerModelId and writerEffort are only a fallback when no runtime evidence exists (caller), and with neither the profile fails closed to small (fallback). The writer profile is reported but never adds a verifier: the plan adds the independent verifier only when escalate names a Task Size high-risk item. ASSESS derives `closed` only from the native candidate.consumed fact for this exact candidate, which native records only when the approved review for that candidate is acknowledged; a caller-supplied closed is not authority and, without that fact, resolves to unknown. Every outcome yields the exact risk-gated plan ASSESS returns when RDD is off: a closed review never removes the separate verifier the tier requires (verify-always-rdd-high S1), unknown is never treated as closed, and a decline is candidate-scoped and never lowers the bar below the RDD-off path. A declined or unavailable outcome, stated explicitly or recorded by this process for this exact candidate (never a different one, and never from repository state alone), wins over closure. The returned outcome_source (explicit|derived|unknown) says which of these produced the value. Legacy controller input remains separate.",
|
|
5297
5852
|
},
|
|
5298
5853
|
outputPath: { type: "string", description: "Retired with legacy bundle export; ignored. Export returns legacy-operation-retired." },
|
|
5299
5854
|
inputPath: { type: "string", description: "Repository-local JSON input file for the separate legacy controller flow (alternative to input). Legacy bundle import is retired." },
|
|
@@ -5564,7 +6119,7 @@ function parseReviewControllerParameters(value: unknown): ReviewControllerParame
|
|
|
5564
6119
|
};
|
|
5565
6120
|
for (const key of ["changeName", "idempotencyKey", "transition", "input", "outputPath", "inputPath", "operationId", "lineageIds", "acknowledgeUntrustedBundleSource", "workspaceRoot"] as const) {
|
|
5566
6121
|
const optional = value[key];
|
|
5567
|
-
if (key === "input" && key in value && (value.operation === REVIEW_CONTROLLER_OPERATION.START || value.operation === REVIEW_CONTROLLER_OPERATION.ASSESS)) {
|
|
6122
|
+
if (key === "input" && key in value && (value.operation === REVIEW_CONTROLLER_OPERATION.START || value.operation === REVIEW_CONTROLLER_OPERATION.ASSESS || value.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT)) {
|
|
5568
6123
|
parameters.input = serializeReviewJsonArgument(optional);
|
|
5569
6124
|
continue;
|
|
5570
6125
|
}
|
|
@@ -5792,21 +6347,18 @@ const REVIEW_MODE_DISABLED_OUTCOME = "review-mode-disabled";
|
|
|
5792
6347
|
// to make the global opt-in first when needed, then clear this clone override.
|
|
5793
6348
|
// Pi never mutates the operator's global gentle-ai state automatically.
|
|
5794
6349
|
//
|
|
5795
|
-
//
|
|
5796
|
-
//
|
|
5797
|
-
//
|
|
5798
|
-
// `default`
|
|
5799
|
-
//
|
|
5800
|
-
//
|
|
5801
|
-
//
|
|
5802
|
-
//
|
|
5803
|
-
// global is the only scope that can turn reviews on at all, and Pi answers the
|
|
5804
|
-
// same. Leaving this undefined would hand the single most common state a dead
|
|
5805
|
-
// end.
|
|
6350
|
+
// Receipt-driven development is on by default (gentle-ai v4.0.0, #4803): an
|
|
6351
|
+
// all-sources-unset install resolves to ON with source `default`. The pinned
|
|
6352
|
+
// v2.4.0 runtime had briefly made it opt-in, which is why this branch exists.
|
|
6353
|
+
// Today `default` only reaches here as gentle-ai's fail-closed projection: the
|
|
6354
|
+
// mode could not be resolved, so it reports OFF with source `default`. The
|
|
6355
|
+
// continuation says so instead of claiming the install never opted in, and
|
|
6356
|
+
// still names `global`, the only scope that can turn reviews on explicitly.
|
|
6357
|
+
// Leaving this undefined would hand that state a dead end.
|
|
5806
6358
|
function reviewModeContinuation(source: NativeReviewModeSource): string | undefined {
|
|
5807
6359
|
if (source === NATIVE_REVIEW_MODE_SOURCE.CLONE_LOCAL) return "Run `gentle-ai review mode enable --scope=global` if global RDD is still off, then run /gentle:review-mode enable to clear this clone-local override.";
|
|
5808
6360
|
if (source === NATIVE_REVIEW_MODE_SOURCE.GLOBAL) return "Run `gentle-ai review mode enable --scope=global` to turn reviews back on; /gentle:review-mode enable only clears the clone-local setting, which cannot override a global off.";
|
|
5809
|
-
return "Run `gentle-ai review mode enable --scope=global` to
|
|
6361
|
+
return "RDD is on by default, but this install could not resolve the review mode and failed closed to off. Run `gentle-ai review mode status` to see why, or `gentle-ai review mode enable --scope=global` to turn reviews on explicitly. /gentle:review-mode enable only clears a clone-local override and cannot enable global RDD.";
|
|
5810
6362
|
}
|
|
5811
6363
|
|
|
5812
6364
|
// Names the situation before the mechanism, then the mechanism, mirroring
|
|
@@ -7459,11 +8011,24 @@ function reviewHostRelayFailureReport(error: ReviewHostRelayError): Record<strin
|
|
|
7459
8011
|
// its provider through the live model registry the caller supplies, or it is
|
|
7460
8012
|
// refused before materialize ever runs (validateReviewerSelectionConfiguration
|
|
7461
8013
|
// in lib/review-host-relay.ts).
|
|
8014
|
+
//
|
|
8015
|
+
// gentle-pi#2271: review-refuter and review-validator are provider review
|
|
8016
|
+
// roles, not installable agents, so nothing seeds their entries and profiles
|
|
8017
|
+
// never carry them. For those two keys only, a snapshot with no model falls
|
|
8018
|
+
// back to Pi's persisted default model and effort, the default the routing
|
|
8019
|
+
// panel already labels them with. Lens keys keep the fail-closed rule.
|
|
7462
8020
|
function reviewHostRelaySelection(lens: string | undefined, config: AgentModelConfig): { selection?: string; thinking?: string; routingKey: string } {
|
|
7463
8021
|
const routingKey = lens === undefined || lens.length === 0 ? "review capture" : lens.startsWith("review-") ? lens : `review-${lens}`;
|
|
7464
8022
|
const entry = config[routingKey];
|
|
7465
|
-
|
|
7466
|
-
|
|
8023
|
+
let model = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).model === "string" && (entry as AgentRoutingEntry).model!.length > 0 ? (entry as AgentRoutingEntry).model : undefined;
|
|
8024
|
+
let thinking = typeof entry === "object" && entry !== null && typeof (entry as AgentRoutingEntry).thinking === "string" ? (entry as AgentRoutingEntry).thinking : undefined;
|
|
8025
|
+
if (model === undefined && isProviderReviewRole(routingKey)) {
|
|
8026
|
+
const persisted = readOrchestratorSettings(orchestratorSettingsPath());
|
|
8027
|
+
if (persisted.status === "valid" && persisted.entry?.model !== undefined) {
|
|
8028
|
+
model = persisted.entry.model;
|
|
8029
|
+
thinking ??= persisted.entry.thinking;
|
|
8030
|
+
}
|
|
8031
|
+
}
|
|
7467
8032
|
return {
|
|
7468
8033
|
...(model === undefined ? {} : { selection: model }),
|
|
7469
8034
|
...(thinking === undefined ? {} : { thinking }),
|
|
@@ -9630,6 +10195,7 @@ export const __testing = {
|
|
|
9630
10195
|
guardedCommandTitle,
|
|
9631
10196
|
loadRuntimeGuardrailsConfig,
|
|
9632
10197
|
isReversibleYoloCommand,
|
|
10198
|
+
evaluateSuperYoloCommand,
|
|
9633
10199
|
yoloConfiguredRestriction,
|
|
9634
10200
|
buildGentlePrompt,
|
|
9635
10201
|
nativeStatusUnsupported,
|
|
@@ -9641,7 +10207,8 @@ export const __testing = {
|
|
|
9641
10207
|
setReviewHostRelayRunnerForTesting,
|
|
9642
10208
|
setReviewHostRelayGroupRunnersForTesting,
|
|
9643
10209
|
clearReviewTransportProbeForTesting,
|
|
9644
|
-
|
|
10210
|
+
routingTablePanel: renderRoutingTableForTesting,
|
|
10211
|
+
piThinkingSupport: getPiThinkingSupport,
|
|
9645
10212
|
getOrchestratorPrompt,
|
|
9646
10213
|
renderOrchestratorPrompt,
|
|
9647
10214
|
loadReviewContractPromptFragment,
|
|
@@ -9700,6 +10267,9 @@ export interface GentleAiRuntimeDependencies {
|
|
|
9700
10267
|
telemetryExecFileAdapter?: ExecFileAdapter;
|
|
9701
10268
|
/** Test-only inference seam; production always uses the active session model. */
|
|
9702
10269
|
autoPermissionClassifier?: AutoRuntimeClassifier;
|
|
10270
|
+
// The backstop samples the measured context at most once per window (its measure rebuilds the whole
|
|
10271
|
+
// session projection). Tests inject a clock or a zero window; production resolves the env and default.
|
|
10272
|
+
contextBackstopMeasure?: { now?: () => number; minIntervalMs?: number };
|
|
9703
10273
|
}
|
|
9704
10274
|
|
|
9705
10275
|
export function createGentleAiExtension(dependencies: GentleAiRuntimeDependencies = {}): (pi: ExtensionAPI) => void {
|
|
@@ -10247,7 +10817,7 @@ function createGentleAiExtensionForTesting(
|
|
|
10247
10817
|
const modelResult = await applySavedModelConfig(ctx);
|
|
10248
10818
|
if (ctx.hasUI && modelResult.invalidPath) {
|
|
10249
10819
|
ctx.ui.notify(
|
|
10250
|
-
`el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:
|
|
10820
|
+
`el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:profiles again.`,
|
|
10251
10821
|
"warning",
|
|
10252
10822
|
);
|
|
10253
10823
|
return;
|
|
@@ -10358,7 +10928,7 @@ function createGentleAiExtensionForTesting(
|
|
|
10358
10928
|
// and forwards only systemPromptOptions, so the harness is delivered
|
|
10359
10929
|
// through the mutable appendSystemPrompt section instead of a replacement.
|
|
10360
10930
|
appendSystemPromptOnce(event.systemPromptOptions, `${gentlePrompt}${reviewContractPrompt}`);
|
|
10361
|
-
updateYoloPrompt(event.systemPromptOptions, isPrimarySession && await yolo.active(ctx));
|
|
10931
|
+
updateYoloPrompt(event.systemPromptOptions, isPrimarySession && await yolo.active(ctx), isPrimarySession && await yolo.superActive(ctx));
|
|
10362
10932
|
updateAutoPrompt(event.systemPromptOptions, isPrimarySession && await yolo.mode(ctx) === "auto");
|
|
10363
10933
|
return undefined;
|
|
10364
10934
|
});
|
|
@@ -10429,14 +10999,23 @@ function createGentleAiExtensionForTesting(
|
|
|
10429
10999
|
} catch { /* Preparation and receipt persistence cannot change a successful tool result. */ }
|
|
10430
11000
|
};
|
|
10431
11001
|
const contextBackstopSignal = createContextBackstopSignal(permissionEnvironment);
|
|
11002
|
+
// One projection rebuild per session per window, not one per tool result: the measured context is
|
|
11003
|
+
// sampled (see createThrottledContextMeasure), so long sessions stop paying an O(session) scan on
|
|
11004
|
+
// every tool result while the backstop keeps its once-per-crossing notice semantics.
|
|
11005
|
+
const contextBackstopMeasure = createThrottledContextMeasure({
|
|
11006
|
+
now: dependencies.contextBackstopMeasure?.now,
|
|
11007
|
+
minIntervalMs: dependencies.contextBackstopMeasure?.minIntervalMs,
|
|
11008
|
+
env: permissionEnvironment,
|
|
11009
|
+
});
|
|
10432
11010
|
pi.on("tool_result", async (event, ctx) => {
|
|
10433
|
-
// Harness roadmap R1: the primary session's measured context is
|
|
10434
|
-
//
|
|
10435
|
-
//
|
|
11011
|
+
// Harness roadmap R1: the primary session's measured context is sampled against the backstop; the
|
|
11012
|
+
// first sampled result at or past it carries one notice, after any other patch below. The sample
|
|
11013
|
+
// runs at most once per window per session (default 5 s, GENTLE_PI_CONTEXT_BACKSTOP_MEASURE_MS),
|
|
11014
|
+
// so a crossing is announced on the next sampled result — never later than one window past it.
|
|
10436
11015
|
const sessionKey = pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey);
|
|
10437
11016
|
const primary = permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && (processAgentEndSubagentDepth.get(sessionKey) ?? 0) === 0;
|
|
10438
11017
|
let notice: string | undefined;
|
|
10439
|
-
try { notice = primary ? contextBackstopSignal(sessionKey, ctx.getContextUsage?.()) : undefined; } catch { notice = undefined; }
|
|
11018
|
+
try { notice = primary ? contextBackstopSignal(sessionKey, contextBackstopMeasure(sessionKey, () => ctx.getContextUsage?.())) : undefined; } catch { notice = undefined; }
|
|
10440
11019
|
const withNotice = (patch: ToolResultEventResult | undefined) => notice ? appendContextBackstopNotice(event, patch, notice) : patch;
|
|
10441
11020
|
// Harness roadmap T4: a parent commit reports the branch's authored
|
|
10442
11021
|
// changed lines so delivery slicing reads a number, not an estimate.
|
|
@@ -10455,7 +11034,11 @@ function createGentleAiExtensionForTesting(
|
|
|
10455
11034
|
return { block: true, reason: `${event.toolName} runs only as a direct tool call: call it directly, never from codemode or another tool.` };
|
|
10456
11035
|
}
|
|
10457
11036
|
const primaryToolCall = permissionEnvironment.GENTLE_PI_AGENTS_CHILD !== "1" && (processAgentEndSubagentDepth.get(pendingReviewConsentSessionKey(ctx, pendingReviewConsentFallbackKey)) ?? 0) === 0;
|
|
10458
|
-
|
|
11037
|
+
// One early mode read settles the session binding (and its one-time epoch
|
|
11038
|
+
// bump) before the guarded region below captures its own epoch.
|
|
11039
|
+
const settledMode = primaryToolCall ? await yolo.mode(ctx, { toolCallId: event.toolCallId }) : "normal";
|
|
11040
|
+
const yoloActive = primaryToolCall && settledMode === "yolo";
|
|
11041
|
+
const superYoloActive = primaryToolCall && settledMode === "super-yolo";
|
|
10459
11042
|
const sensitivePathDenied = evaluateSensitivePathTool(
|
|
10460
11043
|
event.toolName,
|
|
10461
11044
|
event.input,
|
|
@@ -10501,13 +11084,17 @@ function createGentleAiExtensionForTesting(
|
|
|
10501
11084
|
if (childDenied) return childDenied;
|
|
10502
11085
|
}
|
|
10503
11086
|
const modeEpoch = yolo.epoch();
|
|
11087
|
+
// The admission's authoritative mode read is always a fresh capture, never
|
|
11088
|
+
// the admission slot: a reselection while this lookup is in flight must
|
|
11089
|
+
// invalidate it (mode() answers Normal, the epoch check below blocks), and
|
|
11090
|
+
// tests/auto-mode-invariants.test.ts holds exactly this lookup to lock it.
|
|
10504
11091
|
const permissionMode = primaryToolCall ? await yolo.mode(ctx) : "normal";
|
|
10505
11092
|
// A superseded lookup returns Normal for display, not permission to skip Auto.
|
|
10506
11093
|
if (primaryToolCall && modeEpoch !== yolo.epoch()) {
|
|
10507
11094
|
return { block: true, reason: "Permission mode changed during tool admission. This action was not run; retry under the current mode." };
|
|
10508
11095
|
}
|
|
10509
11096
|
const autoGate = permissionMode === "auto" ? gate : undefined;
|
|
10510
|
-
return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive, autoGate);
|
|
11097
|
+
return await confirmCommand(command, ctx, pi.events, herdrLifecycle, yoloActive, autoGate, superYoloActive);
|
|
10511
11098
|
});
|
|
10512
11099
|
|
|
10513
11100
|
for (const owner of ["delegation", "review"] as const) {
|
|
@@ -10525,17 +11112,10 @@ function createGentleAiExtensionForTesting(
|
|
|
10525
11112
|
});
|
|
10526
11113
|
}
|
|
10527
11114
|
|
|
10528
|
-
pi.registerCommand("gentle:models", {
|
|
10529
|
-
description: "Configure global per-agent models for el Gentleman.",
|
|
10530
|
-
handler: async (_args, ctx) => {
|
|
10531
|
-
await handleModelsCommand(ctx, pi);
|
|
10532
|
-
},
|
|
10533
|
-
});
|
|
10534
|
-
|
|
10535
11115
|
pi.registerCommand("gentle:profiles", {
|
|
10536
|
-
description: "
|
|
10537
|
-
handler: async (
|
|
10538
|
-
await handleProfilesCommand(ctx, pi);
|
|
11116
|
+
description: "Open the models screen (profiles as tabs). Subcommands: export <name>, import, publish <name>, export-routing, restore-routing.",
|
|
11117
|
+
handler: async (args, ctx) => {
|
|
11118
|
+
await handleProfilesCommand(ctx, pi, typeof args === "string" ? args : "");
|
|
10539
11119
|
},
|
|
10540
11120
|
});
|
|
10541
11121
|
|