Files
my-pi/pi-rtk-optimizer/src/config-modal.ts
T

587 lines
19 KiB
TypeScript

import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { SettingItem } from "@earendil-works/pi-tui";
import { toOnOff } from "./boolean-format.js";
import type { RtkIntegrationController } from "./command-register.js";
import { ZellijModal, ZellijSettingsModal } from "./zellij-modal.js";
import {
DEFAULT_RTK_INTEGRATION_CONFIG,
RTK_SOURCE_FILTER_LEVELS,
type RtkIntegrationConfig,
type RuntimeStatus,
} from "./types.js";
interface SettingValueSyncTarget {
updateValue(id: string, value: string): void;
}
const ON_OFF = ["on", "off"];
const MODE_VALUES = ["rewrite", "suggest"];
const SOURCE_FILTER_VALUES = [...RTK_SOURCE_FILTER_LEVELS];
const TRUNCATE_MAX_CHAR_VALUES = ["4000", "8000", "12000", "20000", "50000", "100000", "200000"];
const SMART_TRUNCATE_LINE_VALUES = ["40", "80", "120", "160", "220", "320", "500", "1000", "2000", "4000"];
const RTK_USAGE_TEXT =
"Usage: /rtk [show|path|verify|stats|clear-stats|reset|help] (or run /rtk with no args to open settings modal)";
const SETTINGS_TAB_DEFINITIONS = [
{
label: "General",
settingIds: ["enabled", "mode", "showRewriteNotifications", "guardWhenRtkMissing"],
},
{
label: "Compaction",
settingIds: [
"outputCompactionEnabled",
"outputStripAnsi",
"outputAggregateTestOutput",
"outputFilterBuildOutput",
"outputCompactGitOutput",
"outputAggregateLinterOutput",
"outputGroupSearchOutput",
"outputTrackSavings",
],
},
{
label: "Read & Source",
settingIds: [
"outputReadCompactionEnabled",
"outputSourceFilteringEnabled",
"outputSourceFiltering",
"outputPreserveExactSkillReads",
],
},
{
label: "Truncation",
settingIds: [
"outputTruncateEnabled",
"outputTruncateMaxChars",
"outputSmartTruncate",
"outputSmartTruncateMaxLines",
],
},
] as const;
function buildTabbedSettingGroups(settings: SettingItem[]): Array<{ label: string; settings: SettingItem[] }> {
const byId = new Map(settings.map((setting) => [setting.id, setting]));
const assignedIds = new Set<string>();
const tabs = SETTINGS_TAB_DEFINITIONS.map(({ label, settingIds }) => ({
label,
settings: settingIds.map((id) => {
const setting = byId.get(id);
if (!setting) {
throw new Error(`Missing setting item for tab '${label}': ${id}`);
}
if (assignedIds.has(id)) {
throw new Error(`Setting item assigned to multiple tabs: ${id}`);
}
assignedIds.add(id);
return setting;
}),
}));
const unassignedIds = settings.map((setting) => setting.id).filter((id) => !assignedIds.has(id));
if (unassignedIds.length > 0) {
throw new Error(`Unassigned setting items: ${unassignedIds.join(", ")}`);
}
return tabs;
}
function parseSourceFilterLevel(
value: string,
): RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"] | undefined {
return SOURCE_FILTER_VALUES.includes(value as (typeof SOURCE_FILTER_VALUES)[number])
? (value as RtkIntegrationConfig["outputCompaction"]["sourceCodeFiltering"])
: undefined;
}
function parseIntegerInRange(value: string, min: number, max: number): number | undefined {
if (!/^\d+$/.test(value)) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
return undefined;
}
return parsed;
}
function summarizeRuntimeStatus(runtimeStatus: RuntimeStatus): string {
const runtime = runtimeStatus.rtkAvailable
? "rtk=available"
: `rtk=missing${runtimeStatus.lastError ? ` (${runtimeStatus.lastError})` : ""}`;
const executable = runtimeStatus.rtkExecutablePath
? `, rtkPath=${runtimeStatus.rtkExecutablePath}`
: runtimeStatus.rtkExecutableResolutionWarning
? `, rtkPath=unresolved (${runtimeStatus.rtkExecutableResolutionWarning})`
: "";
return `${runtime}${executable}`;
}
function summarizeConfig(config: RtkIntegrationConfig, runtimeStatus: RuntimeStatus): string {
return `enabled=${config.enabled}, commandRewriting=${config.commandRewritingEnabled}, mode=${config.mode}, rewriteSource=rtk, rewriteNotice=${config.showRewriteNotifications}, compaction=${config.outputCompaction.enabled}, readCompaction=${config.outputCompaction.readCompaction.enabled}, sourceFilterEnabled=${config.outputCompaction.sourceCodeFilteringEnabled}, preserveSkillReads=${config.outputCompaction.preserveExactSkillReads}, sourceFilter=${config.outputCompaction.sourceCodeFiltering}, ${summarizeRuntimeStatus(runtimeStatus)}`;
}
function buildSettingItems(config: RtkIntegrationConfig): SettingItem[] {
return [
{
id: "enabled",
label: "RTK integration enabled",
description: "Master switch for rewrite, suggestions, and output compaction",
currentValue: toOnOff(config.enabled),
values: ON_OFF,
},
{
id: "commandRewritingEnabled",
label: "RTK command rewriting",
description: "Optional RTK CLI command rewriting; off keeps FFF in full control of search",
currentValue: toOnOff(config.commandRewritingEnabled),
values: ON_OFF,
},
{
id: "mode",
label: "Rewrite mode",
description: "rewrite = auto-rewrite bash commands, suggest = notify only",
currentValue: config.mode,
values: MODE_VALUES,
},
{
id: "showRewriteNotifications",
label: "Show rewrite notifications",
description: "Show 'RTK rewrite: old -> new' notice in TUI",
currentValue: toOnOff(config.showRewriteNotifications),
values: ON_OFF,
},
{
id: "guardWhenRtkMissing",
label: "Guard when rtk missing",
description: "If on, raw commands run unchanged when rtk binary is unavailable",
currentValue: toOnOff(config.guardWhenRtkMissing),
values: ON_OFF,
},
{
id: "outputCompactionEnabled",
label: "Output compaction enabled",
description: "Compact bash/read tool results to reduce token usage; search results are untouched",
currentValue: toOnOff(config.outputCompaction.enabled),
values: ON_OFF,
},
{
id: "outputStripAnsi",
label: "Strip ANSI in output",
description: "Remove color/control codes from tool output before further compaction",
currentValue: toOnOff(config.outputCompaction.stripAnsi),
values: ON_OFF,
},
{
id: "outputReadCompactionEnabled",
label: "Read compaction enabled",
description: "If off, read tool output stays exact; build/test/git/grep compaction can still run",
currentValue: toOnOff(config.outputCompaction.readCompaction.enabled),
values: ON_OFF,
},
{
id: "outputTruncateEnabled",
label: "Hard truncation enabled",
description: "Apply max character cap after other compaction techniques",
currentValue: toOnOff(config.outputCompaction.truncate.enabled),
values: ON_OFF,
},
{
id: "outputTruncateMaxChars",
label: "Hard truncation max chars",
description: "Maximum characters kept when hard truncation is enabled",
currentValue: String(config.outputCompaction.truncate.maxChars),
values: TRUNCATE_MAX_CHAR_VALUES,
},
{
id: "outputSourceFilteringEnabled",
label: "Read source filtering enabled",
description: "If off, read output skips source-code filtering regardless of selected level",
currentValue: toOnOff(config.outputCompaction.sourceCodeFilteringEnabled),
values: ON_OFF,
},
{
id: "outputPreserveExactSkillReads",
label: "Preserve exact skill reads",
description: "If on, read results under the global Pi skills directory (default: ~/.pi/agent/skills, respects PI_CODING_AGENT_DIR), ~/.agents/skills, .pi/skills, and ancestor .agents/skills skip read compaction",
currentValue: toOnOff(config.outputCompaction.preserveExactSkillReads),
values: ON_OFF,
},
{
id: "outputSourceFiltering",
label: "Read source filtering",
description: "none|minimal|aggressive for read output compaction",
currentValue: config.outputCompaction.sourceCodeFiltering,
values: SOURCE_FILTER_VALUES,
},
{
id: "outputSmartTruncate",
label: "Read smart truncation",
description: "Keep signatures/imports when read output has many lines",
currentValue: toOnOff(config.outputCompaction.smartTruncate.enabled),
values: ON_OFF,
},
{
id: "outputSmartTruncateMaxLines",
label: "Read smart truncation max lines",
description: "Target max lines for smart truncation in read outputs",
currentValue: String(config.outputCompaction.smartTruncate.maxLines),
values: SMART_TRUNCATE_LINE_VALUES,
},
{
id: "outputAggregateTestOutput",
label: "Aggregate test output",
description: "Summarize test command output to failures and key totals",
currentValue: toOnOff(config.outputCompaction.aggregateTestOutput),
values: ON_OFF,
},
{
id: "outputFilterBuildOutput",
label: "Filter build output",
description: "Reduce build noise and keep key error/warning lines",
currentValue: toOnOff(config.outputCompaction.filterBuildOutput),
values: ON_OFF,
},
{
id: "outputCompactGitOutput",
label: "Compact git output",
description: "Condense git command output for lower token usage",
currentValue: toOnOff(config.outputCompaction.compactGitOutput),
values: ON_OFF,
},
{
id: "outputAggregateLinterOutput",
label: "Aggregate linter output",
description: "Summarize linter output by file and issue type",
currentValue: toOnOff(config.outputCompaction.aggregateLinterOutput),
values: ON_OFF,
},
{
id: "outputTrackSavings",
label: "Track output savings",
description: "Collect in-session compaction metrics for /rtk stats",
currentValue: toOnOff(config.outputCompaction.trackSavings),
values: ON_OFF,
},
];
}
function applySetting(config: RtkIntegrationConfig, id: string, value: string): RtkIntegrationConfig {
switch (id) {
case "enabled":
return { ...config, enabled: value === "on" };
case "commandRewritingEnabled":
return { ...config, commandRewritingEnabled: value === "on" };
case "mode":
return { ...config, mode: value === "suggest" ? "suggest" : "rewrite" };
case "showRewriteNotifications":
return { ...config, showRewriteNotifications: value === "on" };
case "guardWhenRtkMissing":
return { ...config, guardWhenRtkMissing: value === "on" };
case "outputCompactionEnabled":
return {
...config,
outputCompaction: { ...config.outputCompaction, enabled: value === "on" },
};
case "outputStripAnsi":
return {
...config,
outputCompaction: { ...config.outputCompaction, stripAnsi: value === "on" },
};
case "outputReadCompactionEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
readCompaction: { enabled: value === "on" },
},
};
case "outputTruncateEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
truncate: {
...config.outputCompaction.truncate,
enabled: value === "on",
},
},
};
case "outputTruncateMaxChars": {
const parsed = parseIntegerInRange(value, 1_000, 200_000);
return {
...config,
outputCompaction: {
...config.outputCompaction,
truncate: {
...config.outputCompaction.truncate,
maxChars: parsed ?? config.outputCompaction.truncate.maxChars,
},
},
};
}
case "outputSourceFilteringEnabled":
return {
...config,
outputCompaction: {
...config.outputCompaction,
sourceCodeFilteringEnabled: value === "on",
},
};
case "outputPreserveExactSkillReads":
return {
...config,
outputCompaction: {
...config.outputCompaction,
preserveExactSkillReads: value === "on",
},
};
case "outputSourceFiltering": {
const parsedValue = parseSourceFilterLevel(value);
return {
...config,
outputCompaction: {
...config.outputCompaction,
sourceCodeFiltering: parsedValue ?? config.outputCompaction.sourceCodeFiltering,
},
};
}
case "outputSmartTruncate":
return {
...config,
outputCompaction: {
...config.outputCompaction,
smartTruncate: {
...config.outputCompaction.smartTruncate,
enabled: value === "on",
},
},
};
case "outputSmartTruncateMaxLines": {
const parsed = parseIntegerInRange(value, 40, 4_000);
return {
...config,
outputCompaction: {
...config.outputCompaction,
smartTruncate: {
...config.outputCompaction.smartTruncate,
maxLines: parsed ?? config.outputCompaction.smartTruncate.maxLines,
},
},
};
}
case "outputAggregateTestOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
aggregateTestOutput: value === "on",
},
};
case "outputFilterBuildOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
filterBuildOutput: value === "on",
},
};
case "outputCompactGitOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
compactGitOutput: value === "on",
},
};
case "outputAggregateLinterOutput":
return {
...config,
outputCompaction: {
...config.outputCompaction,
aggregateLinterOutput: value === "on",
},
};
case "outputTrackSavings":
return {
...config,
outputCompaction: {
...config.outputCompaction,
trackSavings: value === "on",
},
};
default:
return config;
}
}
function syncSettingValues(settingsList: SettingValueSyncTarget, config: RtkIntegrationConfig): void {
settingsList.updateValue("enabled", toOnOff(config.enabled));
settingsList.updateValue("commandRewritingEnabled", toOnOff(config.commandRewritingEnabled));
settingsList.updateValue("mode", config.mode);
settingsList.updateValue("showRewriteNotifications", toOnOff(config.showRewriteNotifications));
settingsList.updateValue("guardWhenRtkMissing", toOnOff(config.guardWhenRtkMissing));
settingsList.updateValue("outputCompactionEnabled", toOnOff(config.outputCompaction.enabled));
settingsList.updateValue("outputStripAnsi", toOnOff(config.outputCompaction.stripAnsi));
settingsList.updateValue("outputReadCompactionEnabled", toOnOff(config.outputCompaction.readCompaction.enabled));
settingsList.updateValue("outputTruncateEnabled", toOnOff(config.outputCompaction.truncate.enabled));
settingsList.updateValue("outputTruncateMaxChars", String(config.outputCompaction.truncate.maxChars));
settingsList.updateValue("outputSourceFilteringEnabled", toOnOff(config.outputCompaction.sourceCodeFilteringEnabled));
settingsList.updateValue("outputPreserveExactSkillReads", toOnOff(config.outputCompaction.preserveExactSkillReads));
settingsList.updateValue("outputSourceFiltering", config.outputCompaction.sourceCodeFiltering);
settingsList.updateValue("outputSmartTruncate", toOnOff(config.outputCompaction.smartTruncate.enabled));
settingsList.updateValue("outputSmartTruncateMaxLines", String(config.outputCompaction.smartTruncate.maxLines));
settingsList.updateValue("outputAggregateTestOutput", toOnOff(config.outputCompaction.aggregateTestOutput));
settingsList.updateValue("outputFilterBuildOutput", toOnOff(config.outputCompaction.filterBuildOutput));
settingsList.updateValue("outputCompactGitOutput", toOnOff(config.outputCompaction.compactGitOutput));
settingsList.updateValue("outputAggregateLinterOutput", toOnOff(config.outputCompaction.aggregateLinterOutput));
settingsList.updateValue("outputTrackSavings", toOnOff(config.outputCompaction.trackSavings));
}
async function openSettingsModal(ctx: ExtensionCommandContext, controller: RtkIntegrationController): Promise<void> {
const overlayOptions = { anchor: "center" as const, width: 86, maxHeight: "85%" as const, margin: 1 };
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
let current = controller.getConfig();
let settingsModal: ZellijSettingsModal | null = null;
const allSettings = buildSettingItems(current);
const tabs = buildTabbedSettingGroups(allSettings);
settingsModal = new ZellijSettingsModal(
{
title: "Pi RTK Optimizer",
tabs,
activeTabIndex: 0,
onChange: (id, newValue) => {
current = applySetting(current, id, newValue);
controller.setConfig(current, ctx);
current = controller.getConfig();
if (settingsModal) {
syncSettingValues(settingsModal, current);
}
},
onClose: () => done(),
helpText: `Config: ${controller.getConfigPath()}`,
enableSearch: true,
},
theme,
);
const modal = new ZellijModal(
settingsModal,
{
borderStyle: "rounded",
titleBar: {
left: "Pi RTK Optimizer",
},
helpUndertitle: {
variants: [
"←/→ tabs • Type to search • Enter/Space change • Esc close",
"←/→ tabs • Type to search • Esc close",
"←/→ tabs • Esc close",
],
color: "dim",
},
overlay: overlayOptions,
},
theme,
);
return {
render(width: number) {
return modal.renderModal(width).lines;
},
invalidate() {
modal.invalidate();
},
handleInput(data: string) {
modal.handleInput(data);
tui.requestRender();
},
};
},
{ overlay: true, overlayOptions },
);
}
async function handleArgs(
args: string,
ctx: ExtensionCommandContext,
controller: RtkIntegrationController,
): Promise<boolean> {
const normalized = (args ?? "").trim().toLowerCase();
if (!normalized) {
return false;
}
if (normalized === "help") {
ctx.ui.notify(RTK_USAGE_TEXT, "info");
return true;
}
if (normalized === "show") {
ctx.ui.notify(`rtk: ${summarizeConfig(controller.getConfig(), controller.getRuntimeStatus())}`, "info");
return true;
}
if (normalized === "path") {
ctx.ui.notify(`rtk config: ${controller.getConfigPath()}`, "info");
return true;
}
if (normalized === "verify") {
const runtimeStatus = await controller.refreshRuntimeStatus();
if (runtimeStatus.rtkAvailable) {
const pathDetail = runtimeStatus.rtkExecutablePath ? ` at ${runtimeStatus.rtkExecutablePath}` : "";
ctx.ui.notify(`RTK binary is available${pathDetail}.`, "info");
} else {
ctx.ui.notify(
`RTK binary is not available${runtimeStatus.lastError ? `: ${runtimeStatus.lastError}` : ""}.`,
"warning",
);
}
return true;
}
if (normalized === "stats") {
ctx.ui.notify(controller.getMetricsSummary(), "info");
return true;
}
if (normalized === "clear-stats") {
controller.clearMetrics();
ctx.ui.notify("RTK metrics cleared.", "info");
return true;
}
if (normalized === "reset") {
controller.setConfig({ ...DEFAULT_RTK_INTEGRATION_CONFIG }, ctx);
ctx.ui.notify("RTK integration settings reset to defaults.", "info");
return true;
}
ctx.ui.notify(RTK_USAGE_TEXT, "warning");
return true;
}
export async function handleRtkIntegrationCommand(
args: string,
ctx: ExtensionCommandContext,
controller: RtkIntegrationController,
): Promise<void> {
if (await handleArgs(args, ctx, controller)) {
return;
}
if (!ctx.hasUI) {
ctx.ui.notify("/rtk requires interactive TUI mode.", "warning");
return;
}
await openSettingsModal(ctx, controller);
}