feat(ui): Improved project approval settings
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
Text,
|
||||
} from "@earendil-works/pi-tui";
|
||||
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
|
||||
import type { WarningSettings } from "../../../core/settings-manager.ts";
|
||||
import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts";
|
||||
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyDisplayText } from "./keybinding-hints.ts";
|
||||
@@ -31,6 +31,16 @@ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
|
||||
xhigh: "Maximum reasoning (~32k tokens)",
|
||||
};
|
||||
|
||||
const DEFAULT_PROJECT_TRUST_LABELS: Record<DefaultProjectTrust, string> = {
|
||||
ask: "Ask",
|
||||
always: "Always trust",
|
||||
never: "Never trust",
|
||||
};
|
||||
|
||||
const DEFAULT_PROJECT_TRUST_BY_LABEL = new Map(
|
||||
Object.entries(DEFAULT_PROJECT_TRUST_LABELS).map(([value, label]) => [label, value as DefaultProjectTrust]),
|
||||
);
|
||||
|
||||
export interface SettingsConfig {
|
||||
autoCompact: boolean;
|
||||
showImages: boolean;
|
||||
@@ -55,6 +65,7 @@ export interface SettingsConfig {
|
||||
editorPaddingX: number;
|
||||
autocompleteMaxVisible: number;
|
||||
quietStartup: boolean;
|
||||
defaultProjectTrust: DefaultProjectTrust;
|
||||
clearOnShrink: boolean;
|
||||
showTerminalProgress: boolean;
|
||||
warnings: WarningSettings;
|
||||
@@ -83,6 +94,7 @@ export interface SettingsCallbacks {
|
||||
onEditorPaddingXChange: (padding: number) => void;
|
||||
onAutocompleteMaxVisibleChange: (maxVisible: number) => void;
|
||||
onQuietStartupChange: (enabled: boolean) => void;
|
||||
onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void;
|
||||
onClearOnShrinkChange: (enabled: boolean) => void;
|
||||
onShowTerminalProgressChange: (enabled: boolean) => void;
|
||||
onWarningsChange: (warnings: WarningSettings) => void;
|
||||
@@ -277,6 +289,13 @@ export class SettingsSelectorComponent extends Container {
|
||||
currentValue: config.enableInstallTelemetry ? "true" : "false",
|
||||
values: ["true", "false"],
|
||||
},
|
||||
{
|
||||
id: "default-project-trust",
|
||||
label: "Default project trust",
|
||||
description: "Fallback behavior when no extension or saved trust decision decides project trust",
|
||||
currentValue: DEFAULT_PROJECT_TRUST_LABELS[config.defaultProjectTrust],
|
||||
values: Object.values(DEFAULT_PROJECT_TRUST_LABELS),
|
||||
},
|
||||
{
|
||||
id: "double-escape-action",
|
||||
label: "Double-escape action",
|
||||
@@ -512,6 +531,13 @@ export class SettingsSelectorComponent extends Container {
|
||||
case "install-telemetry":
|
||||
callbacks.onEnableInstallTelemetryChange(newValue === "true");
|
||||
break;
|
||||
case "default-project-trust": {
|
||||
const defaultProjectTrust = DEFAULT_PROJECT_TRUST_BY_LABEL.get(newValue);
|
||||
if (defaultProjectTrust) {
|
||||
callbacks.onDefaultProjectTrustChange(defaultProjectTrust);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "double-escape-action":
|
||||
callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree");
|
||||
break;
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
import { Container, getKeybindings, Spacer, Text } from "@earendil-works/pi-tui";
|
||||
import type { ProjectTrustDecision } from "../../../core/trust-manager.ts";
|
||||
import {
|
||||
getProjectTrustOptions,
|
||||
getProjectTrustPath,
|
||||
type ProjectTrustOption,
|
||||
type ProjectTrustStoreEntry,
|
||||
} from "../../../core/trust-manager.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
|
||||
|
||||
interface TrustOption {
|
||||
label: string;
|
||||
trusted: boolean;
|
||||
}
|
||||
export type TrustSelection = Pick<ProjectTrustOption, "trusted" | "updates">;
|
||||
|
||||
export interface TrustSelectorOptions {
|
||||
cwd: string;
|
||||
savedDecision: ProjectTrustDecision;
|
||||
savedDecision: ProjectTrustStoreEntry | null;
|
||||
projectTrusted: boolean;
|
||||
onSelect: (trusted: boolean) => void;
|
||||
onSelect: (selection: TrustSelection) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TRUST_OPTIONS: TrustOption[] = [
|
||||
{ label: "Trust", trusted: true },
|
||||
{ label: "Do not trust", trusted: false },
|
||||
];
|
||||
|
||||
function formatDecision(decision: ProjectTrustDecision): string {
|
||||
if (decision === true) {
|
||||
return "trusted";
|
||||
function formatDecision(cwd: string, decision: ProjectTrustStoreEntry | null): string {
|
||||
if (decision === null) {
|
||||
return "none";
|
||||
}
|
||||
if (decision === false) {
|
||||
return "untrusted";
|
||||
const label = decision.decision ? "trusted" : "untrusted";
|
||||
if (decision.path !== getProjectTrustPath(cwd)) {
|
||||
return `${label} (inherited from ${decision.path})`;
|
||||
}
|
||||
return "none";
|
||||
return `${label} (${decision.path})`;
|
||||
}
|
||||
|
||||
export class TrustSelectorComponent extends Container {
|
||||
private selectedIndex: number;
|
||||
private readonly listContainer: Container;
|
||||
private readonly savedDecision: ProjectTrustDecision;
|
||||
private readonly onSelectCallback: (trusted: boolean) => void;
|
||||
private readonly trustOptions: ProjectTrustOption[];
|
||||
private readonly savedDecision: ProjectTrustStoreEntry | null;
|
||||
private readonly onSelectCallback: (selection: TrustSelection) => void;
|
||||
private readonly onCancelCallback: () => void;
|
||||
|
||||
constructor(options: TrustSelectorOptions) {
|
||||
super();
|
||||
|
||||
this.savedDecision = options.savedDecision;
|
||||
this.trustOptions = getProjectTrustOptions(options.cwd);
|
||||
this.selectedIndex = Math.max(
|
||||
0,
|
||||
TRUST_OPTIONS.findIndex((option) => option.trusted === options.savedDecision),
|
||||
this.trustOptions.findIndex((option) => this.isSavedOption(option)),
|
||||
);
|
||||
this.onSelectCallback = options.onSelect;
|
||||
this.onCancelCallback = options.onCancel;
|
||||
@@ -55,7 +55,9 @@ export class TrustSelectorComponent extends Container {
|
||||
this.addChild(new Text(theme.fg("accent", theme.bold("Project trust")), 1, 0));
|
||||
this.addChild(new Text(theme.fg("muted", options.cwd), 1, 0));
|
||||
this.addChild(new Spacer(1));
|
||||
this.addChild(new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.savedDecision)}`), 1, 0));
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Saved decision: ${formatDecision(options.cwd, options.savedDecision)}`), 1, 0),
|
||||
);
|
||||
this.addChild(
|
||||
new Text(theme.fg("muted", `Current session: ${options.projectTrusted ? "trusted" : "untrusted"}`), 1, 0),
|
||||
);
|
||||
@@ -81,16 +83,24 @@ export class TrustSelectorComponent extends Container {
|
||||
this.updateList();
|
||||
}
|
||||
|
||||
private isSavedOption(option: ProjectTrustOption): boolean {
|
||||
return (
|
||||
option.savedPath !== undefined &&
|
||||
this.savedDecision?.decision === option.trusted &&
|
||||
this.savedDecision.path === option.savedPath
|
||||
);
|
||||
}
|
||||
|
||||
private updateList(): void {
|
||||
this.listContainer.clear();
|
||||
for (let i = 0; i < TRUST_OPTIONS.length; i++) {
|
||||
const option = TRUST_OPTIONS[i];
|
||||
for (let i = 0; i < this.trustOptions.length; i++) {
|
||||
const option = this.trustOptions[i];
|
||||
if (!option) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const isCurrent = option.trusted === this.savedDecision;
|
||||
const isCurrent = this.isSavedOption(option);
|
||||
const checkmark = isCurrent ? theme.fg("success", " ✓") : "";
|
||||
const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
|
||||
const label = isSelected ? theme.fg("accent", option.label) : theme.fg("text", option.label);
|
||||
@@ -104,12 +114,12 @@ export class TrustSelectorComponent extends Container {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "tui.select.down") || keyData === "j") {
|
||||
this.selectedIndex = Math.min(TRUST_OPTIONS.length - 1, this.selectedIndex + 1);
|
||||
this.selectedIndex = Math.min(this.trustOptions.length - 1, this.selectedIndex + 1);
|
||||
this.updateList();
|
||||
} else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
|
||||
const selected = TRUST_OPTIONS[this.selectedIndex];
|
||||
const selected = this.trustOptions[this.selectedIndex];
|
||||
if (selected) {
|
||||
this.onSelectCallback(selected.trusted);
|
||||
this.onSelectCallback({ trusted: selected.trusted, updates: selected.updates });
|
||||
}
|
||||
} else if (kb.matches(keyData, "tui.select.cancel")) {
|
||||
this.onCancelCallback();
|
||||
|
||||
@@ -3277,7 +3277,7 @@ export class InteractiveMode {
|
||||
new Text(
|
||||
theme.fg(
|
||||
"warning",
|
||||
"This project is not trusted. Project instructions (AGENTS.md/CLAUDE.md), .pi resources, and project packages are ignored. Use /trust to save a trust decision, then restart pi.",
|
||||
"This project is not trusted. Project .pi resources and packages are ignored. Use /trust to save a trust decision, then restart pi.",
|
||||
),
|
||||
1,
|
||||
0,
|
||||
@@ -3966,6 +3966,7 @@ export class InteractiveMode {
|
||||
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
|
||||
treeFilterMode: this.settingsManager.getTreeFilterMode(),
|
||||
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
|
||||
defaultProjectTrust: this.settingsManager.getDefaultProjectTrust(),
|
||||
editorPaddingX: this.settingsManager.getEditorPaddingX(),
|
||||
autocompleteMaxVisible: this.settingsManager.getAutocompleteMaxVisible(),
|
||||
quietStartup: this.settingsManager.getQuietStartup(),
|
||||
@@ -4059,6 +4060,9 @@ export class InteractiveMode {
|
||||
onQuietStartupChange: (enabled) => {
|
||||
this.settingsManager.setQuietStartup(enabled);
|
||||
},
|
||||
onDefaultProjectTrustChange: (defaultProjectTrust) => {
|
||||
this.settingsManager.setDefaultProjectTrust(defaultProjectTrust);
|
||||
},
|
||||
onDoubleEscapeActionChange: (action) => {
|
||||
this.settingsManager.setDoubleEscapeAction(action);
|
||||
},
|
||||
@@ -4213,17 +4217,17 @@ export class InteractiveMode {
|
||||
private showTrustSelector(): void {
|
||||
const cwd = this.sessionManager.getCwd();
|
||||
const trustStore = new ProjectTrustStore(this.runtimeHost.services.agentDir);
|
||||
const savedDecision = trustStore.get(cwd);
|
||||
const savedDecision = trustStore.getEntry(cwd);
|
||||
this.showSelector((done) => {
|
||||
const selector = new TrustSelectorComponent({
|
||||
cwd,
|
||||
savedDecision,
|
||||
projectTrusted: this.settingsManager.isProjectTrusted(),
|
||||
onSelect: (trusted) => {
|
||||
trustStore.set(cwd, trusted);
|
||||
onSelect: (selection) => {
|
||||
trustStore.setMany(selection.updates);
|
||||
done();
|
||||
this.showStatus(
|
||||
`Saved trust decision: ${trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
|
||||
`Saved trust decision: ${selection.trusted ? "trusted" : "untrusted"}. Restart pi for this to take effect.`,
|
||||
);
|
||||
},
|
||||
onCancel: () => {
|
||||
|
||||
Reference in New Issue
Block a user