feat(ui): Improved project approval settings
This commit is contained in:
@@ -7,13 +7,14 @@
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai";
|
||||
import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui";
|
||||
import chalk from "chalk";
|
||||
import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts";
|
||||
import { processFileArguments } from "./cli/file-processor.ts";
|
||||
import { buildInitialMessage } from "./cli/initial-message.ts";
|
||||
import { listModels } from "./cli/list-models.ts";
|
||||
import { createProjectTrustContext } from "./cli/project-trust.ts";
|
||||
import { selectSession } from "./cli/session-picker.ts";
|
||||
import { showStartupSelector } from "./cli/startup-ui.ts";
|
||||
import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts";
|
||||
import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts";
|
||||
import {
|
||||
@@ -24,13 +25,12 @@ import {
|
||||
import { formatNoModelsAvailableMessage } from "./core/auth-guidance.ts";
|
||||
import { AuthStorage } from "./core/auth-storage.ts";
|
||||
import { exportFromFile } from "./core/export-html/index.ts";
|
||||
import { emitProjectTrustEvent } from "./core/extensions/runner.ts";
|
||||
import type { ExtensionFactory, LoadExtensionsResult, ProjectTrustContext } from "./core/extensions/types.ts";
|
||||
import type { ExtensionFactory } from "./core/extensions/types.ts";
|
||||
import { configureHttpDispatcher } from "./core/http-dispatcher.ts";
|
||||
import { KeybindingsManager } from "./core/keybindings.ts";
|
||||
import type { ModelRegistry } from "./core/model-registry.ts";
|
||||
import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts";
|
||||
import { restoreStdout, takeOverStdout } from "./core/output-guard.ts";
|
||||
import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts";
|
||||
import type { CreateAgentSessionOptions } from "./core/sdk.ts";
|
||||
import {
|
||||
formatMissingSessionCwdPrompt,
|
||||
@@ -44,8 +44,6 @@ import { printTimings, resetTimings, time } from "./core/timings.ts";
|
||||
import { hasProjectTrustInputs, ProjectTrustStore } from "./core/trust-manager.ts";
|
||||
import { runMigrations, showDeprecationWarnings } from "./migrations.ts";
|
||||
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes/index.ts";
|
||||
import { ExtensionInputComponent } from "./modes/interactive/components/extension-input.ts";
|
||||
import { ExtensionSelectorComponent } from "./modes/interactive/components/extension-selector.ts";
|
||||
import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme.ts";
|
||||
import { handleConfigCommand, handlePackageCommand } from "./package-manager-cli.ts";
|
||||
import { isLocalPath, normalizePath, resolvePath } from "./utils/paths.ts";
|
||||
@@ -97,16 +95,14 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
|
||||
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
type AppMode = "interactive" | "print" | "json" | "rpc";
|
||||
|
||||
function resolveAppMode(parsed: Args, stdinIsTTY: boolean): AppMode {
|
||||
function resolveAppMode(parsed: Args, stdinIsTTY: boolean, stdoutIsTTY: boolean): AppMode {
|
||||
if (parsed.mode === "rpc") {
|
||||
return "rpc";
|
||||
}
|
||||
if (parsed.mode === "json") {
|
||||
return "json";
|
||||
}
|
||||
if (parsed.print || !stdinIsTTY) {
|
||||
if (parsed.print || !stdinIsTTY || !stdoutIsTTY) {
|
||||
return "print";
|
||||
}
|
||||
return "interactive";
|
||||
@@ -439,87 +435,6 @@ function resolveCliPaths(cwd: string, paths: string[] | undefined): string[] | u
|
||||
return paths?.map((value) => (isLocalPath(value) ? resolvePath(value, cwd) : value));
|
||||
}
|
||||
|
||||
function createStartupTui(settingsManager: SettingsManager): TUI {
|
||||
initTheme(settingsManager.getTheme());
|
||||
setKeybindings(KeybindingsManager.create());
|
||||
const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor());
|
||||
ui.setClearOnShrink(settingsManager.getClearOnShrink());
|
||||
return ui;
|
||||
}
|
||||
|
||||
async function clearStartupTui(ui: TUI): Promise<void> {
|
||||
ui.clear();
|
||||
ui.requestRender();
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
async function showStartupSelector<T>(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
options: Array<{ label: string; value: T }>,
|
||||
): Promise<T | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: T | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const selector = new ExtensionSelectorComponent(
|
||||
title,
|
||||
options.map((option) => option.label),
|
||||
(option) => void finish(options.find((entry) => entry.label === option)?.value),
|
||||
() => void finish(undefined),
|
||||
{ tui: ui },
|
||||
);
|
||||
ui.addChild(selector);
|
||||
ui.setFocus(selector);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function showStartupInput(
|
||||
settingsManager: SettingsManager,
|
||||
title: string,
|
||||
placeholder?: string,
|
||||
): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const ui = createStartupTui(settingsManager);
|
||||
|
||||
let settled = false;
|
||||
const finish = async (result: string | undefined) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
input.dispose();
|
||||
await clearStartupTui(ui);
|
||||
ui.stop();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const input = new ExtensionInputComponent(
|
||||
title,
|
||||
placeholder,
|
||||
(value) => void finish(value),
|
||||
() => void finish(undefined),
|
||||
{
|
||||
tui: ui,
|
||||
},
|
||||
);
|
||||
ui.addChild(input);
|
||||
ui.setFocus(input);
|
||||
ui.start();
|
||||
});
|
||||
}
|
||||
|
||||
async function promptForMissingSessionCwd(
|
||||
issue: SessionCwdIssue,
|
||||
settingsManager: SettingsManager,
|
||||
@@ -530,160 +445,6 @@ async function promptForMissingSessionCwd(
|
||||
]);
|
||||
}
|
||||
|
||||
interface ProjectTrustPromptResult {
|
||||
trusted: boolean;
|
||||
remember: boolean;
|
||||
}
|
||||
|
||||
const PROJECT_TRUST_PROMPT_OPTIONS: Array<{ label: string; value: ProjectTrustPromptResult }> = [
|
||||
{ label: "Trust", value: { trusted: true, remember: true } },
|
||||
{ label: "Trust (this session only)", value: { trusted: true, remember: false } },
|
||||
{ label: "Do not trust", value: { trusted: false, remember: true } },
|
||||
{ label: "Do not trust (this session only)", value: { trusted: false, remember: false } },
|
||||
];
|
||||
|
||||
function formatProjectTrustPrompt(cwd: string): string {
|
||||
return `Trust project folder?\n${cwd}\n\nThis allows pi to read project instructions (AGENTS.md/CLAUDE.md), load .pi settings and resources, install missing project packages, and execute project extensions.`;
|
||||
}
|
||||
|
||||
async function promptForProjectTrust(
|
||||
cwd: string,
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
return showStartupSelector(settingsManager, formatProjectTrustPrompt(cwd), PROJECT_TRUST_PROMPT_OPTIONS);
|
||||
}
|
||||
|
||||
async function promptForProjectTrustWithContext(
|
||||
cwd: string,
|
||||
ctx: ProjectTrustContext,
|
||||
): Promise<ProjectTrustPromptResult | undefined> {
|
||||
const selected = await ctx.ui.select(
|
||||
formatProjectTrustPrompt(cwd),
|
||||
PROJECT_TRUST_PROMPT_OPTIONS.map((option) => option.label),
|
||||
);
|
||||
return PROJECT_TRUST_PROMPT_OPTIONS.find((option) => option.label === selected)?.value;
|
||||
}
|
||||
|
||||
function createProjectTrustContext(options: {
|
||||
cwd: string;
|
||||
mode: AppMode;
|
||||
settingsManager: SettingsManager;
|
||||
hasUI: boolean;
|
||||
}): ProjectTrustContext {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
mode: options.mode === "interactive" ? "tui" : options.mode,
|
||||
hasUI: options.hasUI,
|
||||
ui: {
|
||||
select: async (title, selectOptions) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupSelector(
|
||||
options.settingsManager,
|
||||
title,
|
||||
selectOptions.map((option) => ({ label: option, value: option })),
|
||||
);
|
||||
},
|
||||
confirm: async (title, message) => {
|
||||
if (!options.hasUI) {
|
||||
return false;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
(await showStartupSelector(options.settingsManager, `${title}\n${message}`, [
|
||||
{ label: "Yes", value: true },
|
||||
{ label: "No", value: false },
|
||||
])) ?? false
|
||||
);
|
||||
},
|
||||
input: async (title, placeholder) => {
|
||||
if (!options.hasUI) {
|
||||
return undefined;
|
||||
}
|
||||
if (options.mode !== "interactive") {
|
||||
return undefined;
|
||||
}
|
||||
return showStartupInput(options.settingsManager, title, placeholder);
|
||||
},
|
||||
notify: (message, type = "info") => {
|
||||
if (options.mode !== "interactive") {
|
||||
const color = type === "error" ? chalk.red : type === "warning" ? chalk.yellow : chalk.cyan;
|
||||
console.error(color(message));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectTrusted(options: {
|
||||
cwd: string;
|
||||
trustStore: ProjectTrustStore;
|
||||
trustOverride?: boolean;
|
||||
appMode: AppMode;
|
||||
settingsManagerForPrompt: SettingsManager;
|
||||
extensionsResult?: LoadExtensionsResult;
|
||||
projectTrustContext?: ProjectTrustContext;
|
||||
onExtensionError?: (message: string) => void;
|
||||
}): Promise<boolean> {
|
||||
if (options.trustOverride !== undefined) {
|
||||
return options.trustOverride;
|
||||
}
|
||||
if (!hasProjectTrustInputs(options.cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (options.extensionsResult && options.projectTrustContext) {
|
||||
const { result, errors } = await emitProjectTrustEvent(
|
||||
options.extensionsResult,
|
||||
{ type: "project_trust", cwd: options.cwd },
|
||||
options.projectTrustContext,
|
||||
);
|
||||
for (const error of errors) {
|
||||
options.onExtensionError?.(`Extension "${error.extensionPath}" project_trust error: ${error.error}`);
|
||||
}
|
||||
if (result) {
|
||||
const trusted = result.trusted === "yes";
|
||||
if (result.remember === true) {
|
||||
options.trustStore.set(options.cwd, trusted);
|
||||
}
|
||||
return trusted;
|
||||
}
|
||||
}
|
||||
|
||||
const decision = options.trustStore.get(options.cwd);
|
||||
if (decision !== null) {
|
||||
return decision;
|
||||
}
|
||||
if (options.projectTrustContext?.hasUI) {
|
||||
const selected = await promptForProjectTrustWithContext(options.cwd, options.projectTrustContext);
|
||||
if (selected !== undefined) {
|
||||
if (selected.remember) {
|
||||
options.trustStore.set(options.cwd, selected.trusted);
|
||||
}
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (options.appMode !== "interactive") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selected = await promptForProjectTrust(options.cwd, options.settingsManagerForPrompt);
|
||||
if (selected !== undefined) {
|
||||
if (selected.remember) {
|
||||
options.trustStore.set(options.cwd, selected.trusted);
|
||||
}
|
||||
return selected.trusted;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface MainOptions {
|
||||
extensionFactories?: ExtensionFactory[];
|
||||
}
|
||||
@@ -700,11 +461,11 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
cleanupWindowsSelfUpdateQuarantine(getPackageDir());
|
||||
}
|
||||
|
||||
if (await handlePackageCommand(args)) {
|
||||
if (await handlePackageCommand(args, { extensionFactories: options?.extensionFactories })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await handleConfigCommand(args)) {
|
||||
if (await handleConfigCommand(args, { extensionFactories: options?.extensionFactories })) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -719,7 +480,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
}
|
||||
}
|
||||
time("parseArgs");
|
||||
let appMode = resolveAppMode(parsed, process.stdin.isTTY);
|
||||
let appMode = resolveAppMode(parsed, process.stdin.isTTY, process.stdout.isTTY);
|
||||
const shouldTakeOverStdout = appMode !== "interactive";
|
||||
if (shouldTakeOverStdout) {
|
||||
takeOverStdout();
|
||||
@@ -837,8 +598,7 @@ export async function main(args: string[], options?: MainOptions) {
|
||||
cwd,
|
||||
trustStore,
|
||||
trustOverride: parsed.projectTrustOverride,
|
||||
appMode: isInitialRuntime ? trustPromptMode : "print",
|
||||
settingsManagerForPrompt: startupSettingsManager,
|
||||
defaultProjectTrust: startupSettingsManager.getDefaultProjectTrust(),
|
||||
extensionsResult,
|
||||
projectTrustContext:
|
||||
projectTrustContext ??
|
||||
|
||||
Reference in New Issue
Block a user