mirror of
https://github.com/kmein/niveum
synced 2026-03-18 02:51:08 +01:00
pi: use from @lassulus
This commit is contained in:
@@ -97,6 +97,7 @@
|
||||
stylix,
|
||||
voidrice,
|
||||
wetter,
|
||||
wrappers,
|
||||
meteora,
|
||||
...
|
||||
}:
|
||||
@@ -314,7 +315,10 @@
|
||||
radio-news = prev.callPackage packages/radio-news { };
|
||||
untilport = prev.callPackage packages/untilport.nix { };
|
||||
weechat-declarative = prev.callPackage packages/weechat-declarative.nix { };
|
||||
pi = prev.callPackage packages/pi.nix { };
|
||||
pi = prev.callPackage packages/pi {
|
||||
pkgs = final;
|
||||
inherit wrappers;
|
||||
};
|
||||
|
||||
# my packages
|
||||
betacode = prev.callPackage packages/betacode.nix { };
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
{
|
||||
runCommand,
|
||||
nodejs,
|
||||
writeShellApplication,
|
||||
lib,
|
||||
jq,
|
||||
cacert,
|
||||
pi-llm,
|
||||
}:
|
||||
let
|
||||
# Pre-install pi plugins into a fake npm global prefix
|
||||
pluginPrefixRaw =
|
||||
runCommand "pi-plugins-raw"
|
||||
{
|
||||
nativeBuildInputs = [
|
||||
nodejs
|
||||
cacert
|
||||
];
|
||||
outputHashMode = "recursive";
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = "sha256-+XaHU/Ale2YLQmdvfG73nG+tjRkWyb27bdLgI3JFLlU=";
|
||||
impureEnvVars = [
|
||||
"http_proxy"
|
||||
"https_proxy"
|
||||
];
|
||||
SSL_CERT_FILE = "${cacert}/etc/ssl/certs/ca-bundle.crt";
|
||||
}
|
||||
''
|
||||
export HOME=$TMPDIR
|
||||
export npm_config_prefix=$out
|
||||
npm install -g pi-hooks shitty-extensions
|
||||
'';
|
||||
|
||||
# Remove the resistance extension (annoying terminator quote widget)
|
||||
pluginPrefix = runCommand "pi-plugins" { } ''
|
||||
cp -a ${pluginPrefixRaw} $out
|
||||
chmod -R u+w $out
|
||||
pkg=$out/lib/node_modules/shitty-extensions/package.json
|
||||
${lib.getExe jq} '.pi.extensions |= map(select(contains("resistance") | not))' "$pkg" > "$pkg.tmp"
|
||||
mv "$pkg.tmp" "$pkg"
|
||||
'';
|
||||
in
|
||||
writeShellApplication {
|
||||
name = "pi";
|
||||
runtimeInputs = [ nodejs ];
|
||||
text = ''
|
||||
set -efu
|
||||
export npm_config_prefix="${pluginPrefix}"
|
||||
|
||||
# Ensure settings.json has our plugins listed
|
||||
SETTINGS_DIR="''${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
||||
SETTINGS_FILE="$SETTINGS_DIR/settings.json"
|
||||
mkdir -p "$SETTINGS_DIR"
|
||||
|
||||
# Add packages to settings if not already present
|
||||
if [ ! -f "$SETTINGS_FILE" ]; then
|
||||
echo '{"packages":["npm:pi-hooks","npm:shitty-extensions"]}' > "$SETTINGS_FILE"
|
||||
else
|
||||
for pkg in "npm:pi-hooks" "npm:shitty-extensions"; do
|
||||
if ! grep -q "$pkg" "$SETTINGS_FILE"; then
|
||||
${lib.getExe jq} --arg p "$pkg" '.packages = ((.packages // []) + [$p] | unique)' "$SETTINGS_FILE" > "$SETTINGS_FILE.tmp"
|
||||
mv "$SETTINGS_FILE.tmp" "$SETTINGS_FILE"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
exec ${lib.getExe pi-llm} "$@"
|
||||
'';
|
||||
}
|
||||
70
packages/pi/default.nix
Normal file
70
packages/pi/default.nix
Normal file
@@ -0,0 +1,70 @@
|
||||
{ pkgs, wrappers }:
|
||||
let
|
||||
piWrapper = wrappers.lib.wrapModule {
|
||||
imports = [ ./module.nix ];
|
||||
inherit pkgs;
|
||||
|
||||
settings = {
|
||||
packages = [
|
||||
{
|
||||
source = "npm:pi-hooks";
|
||||
version = "1.0.3";
|
||||
hash = "sha256-jU3akfqsWgjvOG+8+Md2qEzkXp48LUaXVncpUMXxy7s=";
|
||||
deps = [
|
||||
{
|
||||
source = "npm:shell-quote";
|
||||
version = "1.8.3";
|
||||
hash = "sha256-32QLNUuvjigj1scqLlCVFTfgS3MHm9dBjPk9iVB+IsE=";
|
||||
}
|
||||
{
|
||||
source = "npm:vscode-languageserver-protocol";
|
||||
version = "3.17.5";
|
||||
hash = "sha256-dHPrLSFj8/i+oJZE+dgDeJoZXllrZdOUbEFX5YPjzMg=";
|
||||
deps = [
|
||||
{
|
||||
source = "npm:vscode-jsonrpc";
|
||||
version = "8.2.0";
|
||||
hash = "sha256-PaRFMcOY8VRQdMtyjjWagi81ufiscXHIR/QvByi5x8s=";
|
||||
}
|
||||
{
|
||||
source = "npm:vscode-languageserver-types";
|
||||
version = "3.17.5";
|
||||
hash = "sha256-1nP55/i75RNRvlHFjzLU3PqXpnDruGvGMzaDlMYJysA=";
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
source = "npm:shitty-extensions";
|
||||
version = "1.0.9";
|
||||
hash = "sha256-g26MZ5x4HUcDai4SXPaOEhqgGGqzAI68znnsCbKJv7E=";
|
||||
extensions = [ "!extensions/resistance.ts" ];
|
||||
}
|
||||
];
|
||||
extensions = [
|
||||
./questionnaire.ts
|
||||
];
|
||||
defaultProvider = "anthropic";
|
||||
defaultModel = "claude-opus-4-6";
|
||||
defaultThinkingLevel = "medium";
|
||||
permissionLevel = "low";
|
||||
permissionMode = "ask";
|
||||
permissionConfig.overrides.minimal = [
|
||||
"nix build *"
|
||||
"nix eval *"
|
||||
"nix fmt *"
|
||||
];
|
||||
};
|
||||
|
||||
pluginOverrides = ''
|
||||
# Fix keybinding conflicts in extension source
|
||||
${pkgs.gnused}/bin/sed -i 's/"ctrl+u"/"ctrl+shift+u"/' $out/lib/node_modules/shitty-extensions/extensions/ultrathink.ts
|
||||
${pkgs.gnused}/bin/sed -i 's/"ctrl+r"/"ctrl+shift+r"/' $out/lib/node_modules/shitty-extensions/extensions/speedreading.ts
|
||||
|
||||
# Remove a-nach-b skill
|
||||
rm -rf $out/lib/node_modules/shitty-extensions/skills/a-nach-b
|
||||
'';
|
||||
};
|
||||
in
|
||||
piWrapper.wrapper
|
||||
150
packages/pi/module.nix
Normal file
150
packages/pi/module.nix
Normal file
@@ -0,0 +1,150 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
pkgs = config.pkgs;
|
||||
|
||||
# Extract npm packages with hashes from settings.packages
|
||||
npmPackages = lib.filter (p: builtins.isAttrs p && p ? hash) (config.settings.packages or [ ]);
|
||||
|
||||
# Fetch an npm tarball and unpack it into lib/node_modules/<name>
|
||||
fetchPlugin =
|
||||
pkg:
|
||||
let
|
||||
source = pkg.source or pkg;
|
||||
name = lib.removePrefix "npm:" source;
|
||||
tarball = pkgs.fetchurl {
|
||||
url = "https://registry.npmjs.org/${name}/-/${name}-${pkg.version}.tgz";
|
||||
hash = pkg.hash;
|
||||
};
|
||||
deps = map fetchPlugin (pkg.deps or [ ]);
|
||||
in
|
||||
pkgs.runCommand "pi-plugin-${name}"
|
||||
{
|
||||
nativeBuildInputs = [
|
||||
pkgs.gnutar
|
||||
pkgs.gzip
|
||||
];
|
||||
}
|
||||
''
|
||||
mkdir -p $out/lib/node_modules/${name}
|
||||
tar xzf ${tarball} --strip-components=1 -C $out/lib/node_modules/${name}
|
||||
${lib.concatMapStringsSep "\n" (
|
||||
dep:
|
||||
"mkdir -p $out/lib/node_modules/${name}/node_modules"
|
||||
+ "\ncp -a ${dep}/lib/node_modules/* $out/lib/node_modules/${name}/node_modules/"
|
||||
+ "\ncp -a ${dep}/lib/node_modules/* $out/lib/node_modules/"
|
||||
) deps}
|
||||
'';
|
||||
|
||||
fetchedPlugins = map fetchPlugin npmPackages;
|
||||
|
||||
# Strip hash and null-valued attrs from package entries before writing settings.json
|
||||
cleanPackage =
|
||||
p:
|
||||
if builtins.isAttrs p then
|
||||
lib.filterAttrs (k: v: k != "hash" && k != "version" && k != "deps" && v != null) p
|
||||
else
|
||||
p;
|
||||
|
||||
cleanSettings = config.settings // {
|
||||
packages = map cleanPackage (config.settings.packages or [ ]);
|
||||
};
|
||||
|
||||
settingsFile = pkgs.writeText "pi-settings.json" (builtins.toJSON cleanSettings);
|
||||
in
|
||||
{
|
||||
_class = "wrapper";
|
||||
|
||||
options = {
|
||||
settings = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
freeformType = (pkgs.formats.json { }).type;
|
||||
options.packages = lib.mkOption {
|
||||
type = lib.types.listOf (
|
||||
lib.types.either lib.types.str (
|
||||
lib.types.submodule {
|
||||
freeformType = (pkgs.formats.json { }).type;
|
||||
options.hash = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Fixed-output hash for fetching this npm package.";
|
||||
};
|
||||
options.source = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Package source (e.g. npm:package-name).";
|
||||
};
|
||||
options.version = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "Exact version to pin (e.g. 1.0.3). Ensures reproducible fetching.";
|
||||
};
|
||||
options.deps = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.attrsOf lib.types.anything);
|
||||
default = [ ];
|
||||
description = "Transitive npm dependencies as {source, version, hash} attrsets.";
|
||||
};
|
||||
options.extensions = lib.mkOption {
|
||||
type = lib.types.nullOr (lib.types.listOf lib.types.str);
|
||||
default = null;
|
||||
description = ''
|
||||
Extension filter patterns following pi's native syntax.
|
||||
When null (default), all extensions from the package are loaded.
|
||||
An empty list explicitly disables all extensions.
|
||||
Patterns use prefixes to control inclusion:
|
||||
- `"!pattern"` — exclude matching extensions
|
||||
- `"+pattern"` — force-include (overrides excludes)
|
||||
- `"-pattern"` — force-exclude (overrides includes)
|
||||
- `"path"` — plain path to include
|
||||
'';
|
||||
example = [
|
||||
"!./extensions/resistance.ts"
|
||||
];
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
default = [ ];
|
||||
description = "Pi packages list. Object entries may include a `hash` for Nix fetching.";
|
||||
};
|
||||
};
|
||||
default = { };
|
||||
description = "Pi settings.json content.";
|
||||
};
|
||||
|
||||
pluginOverrides = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
description = ''
|
||||
Shell commands to run after assembling the plugin prefix.
|
||||
The variable `$out` points to the mutable copy.
|
||||
'';
|
||||
};
|
||||
|
||||
pluginPrefix = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
description = "The final npm prefix with all plugins merged and overrides applied.";
|
||||
default = pkgs.runCommand "pi-plugins" { } ''
|
||||
mkdir -p $out
|
||||
${lib.concatMapStringsSep "\n" (p: "cp -a --no-preserve=mode ${p}/* $out/") fetchedPlugins}
|
||||
${lib.optionalString (config.pluginOverrides != "") config.pluginOverrides}
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config.package = lib.mkDefault pkgs.pi-llm;
|
||||
|
||||
config.extraPackages = [ pkgs.nodejs ];
|
||||
|
||||
config.env.npm_config_prefix = "${config.pluginPrefix}";
|
||||
config.env.PI_SETTINGS_FILE = "${settingsFile}";
|
||||
|
||||
config.meta.maintainers = [
|
||||
{
|
||||
name = "lassulus";
|
||||
github = "lassulus";
|
||||
githubId = 621375;
|
||||
}
|
||||
];
|
||||
config.meta.platforms = lib.platforms.all;
|
||||
}
|
||||
35
packages/pi/patch-permission-sound.py
Normal file
35
packages/pi/patch-permission-sound.py
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Patch pi-hooks permission extension to play peon sounds via pw-play on Linux."""
|
||||
import re
|
||||
import sys
|
||||
|
||||
ts_file = sys.argv[1]
|
||||
pw_play = sys.argv[2]
|
||||
|
||||
src = open(ts_file).read()
|
||||
|
||||
# Use a lambda for replacement to avoid re.sub interpreting backslash sequences
|
||||
replacement = r"""function playPermissionSound(): void {
|
||||
const isMac = process.platform === "darwin";
|
||||
if (isMac) {
|
||||
exec('afplay /System/Library/Sounds/Funk.aiff 2>/dev/null', (err) => {
|
||||
if (err) process.stdout.write("\x07");
|
||||
});
|
||||
} else {
|
||||
const n = Math.floor(Math.random() * 4) + 1;
|
||||
exec(`""" + pw_play + r""" "$HOME/src/sounds/games/Warcraft III/Units/Orc/Peon/PeonWhat${n}.wav"`, () => {});
|
||||
}
|
||||
}"""
|
||||
|
||||
result = re.sub(
|
||||
r'function playPermissionSound\(\): void \{.*?\n\}',
|
||||
lambda m: replacement,
|
||||
src,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
if result == src:
|
||||
print("ERROR: playPermissionSound function not found in", ts_file, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
open(ts_file, 'w').write(result)
|
||||
463
packages/pi/questionnaire.ts
Normal file
463
packages/pi/questionnaire.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* Questionnaire Tool - Ask the user multiple choice questions
|
||||
*
|
||||
* Registers a "questionnaire" tool the LLM can call to ask single or multiple
|
||||
* questions with an interactive TUI. Based on mic92's pi-agent-extensions.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
Editor,
|
||||
type EditorTheme,
|
||||
Key,
|
||||
matchesKey,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
interface QuestionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
type RenderOption = QuestionOption & { isOther?: boolean };
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
options: QuestionOption[];
|
||||
allowOther: boolean;
|
||||
}
|
||||
|
||||
interface Answer {
|
||||
id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
wasCustom: boolean;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
interface QuestionnaireResult {
|
||||
questions: Question[];
|
||||
answers: Answer[];
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
const QuestionOptionSchema = Type.Object({
|
||||
value: Type.String({ description: "The value returned when selected" }),
|
||||
label: Type.String({ description: "Display label for the option" }),
|
||||
description: Type.Optional(
|
||||
Type.String({ description: "Optional description shown below label" }),
|
||||
),
|
||||
});
|
||||
|
||||
const QuestionSchema = Type.Object({
|
||||
id: Type.String({ description: "Unique identifier for this question" }),
|
||||
label: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
|
||||
}),
|
||||
),
|
||||
prompt: Type.String({ description: "The full question text to display" }),
|
||||
options: Type.Array(QuestionOptionSchema, {
|
||||
description: "Available options to choose from",
|
||||
}),
|
||||
allowOther: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Allow 'Type something' option (default: true)",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const QuestionnaireParams = Type.Object({
|
||||
questions: Type.Array(QuestionSchema, {
|
||||
description: "Questions to ask the user",
|
||||
}),
|
||||
});
|
||||
|
||||
function errorResult(
|
||||
message: string,
|
||||
questions: Question[] = [],
|
||||
): {
|
||||
content: { type: "text"; text: string }[];
|
||||
details: QuestionnaireResult;
|
||||
} {
|
||||
return {
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { questions, answers: [], cancelled: true },
|
||||
};
|
||||
}
|
||||
|
||||
export default function questionnaire(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "questionnaire",
|
||||
label: "Questionnaire",
|
||||
description:
|
||||
"Ask the user one or more multiple choice questions. Use for clarifying requirements, getting preferences, confirming decisions, or quizzing. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
|
||||
parameters: QuestionnaireParams,
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (!ctx.hasUI) {
|
||||
return errorResult(
|
||||
"Error: UI not available (running in non-interactive mode)",
|
||||
);
|
||||
}
|
||||
if (params.questions.length === 0) {
|
||||
return errorResult("Error: No questions provided");
|
||||
}
|
||||
|
||||
const questions: Question[] = params.questions.map((q, i) => ({
|
||||
...q,
|
||||
label: q.label || `Q${i + 1}`,
|
||||
allowOther: q.allowOther !== false,
|
||||
}));
|
||||
|
||||
const isMulti = questions.length > 1;
|
||||
const totalTabs = questions.length + 1;
|
||||
|
||||
const result = await ctx.ui.custom<QuestionnaireResult>(
|
||||
(tui, theme, _kb, done) => {
|
||||
let currentTab = 0;
|
||||
let optionIndex = 0;
|
||||
let inputMode = false;
|
||||
let inputQuestionId: string | null = null;
|
||||
let cachedLines: string[] | undefined;
|
||||
const answers = new Map<string, Answer>();
|
||||
|
||||
const editorTheme: EditorTheme = {
|
||||
borderColor: (s) => theme.fg("accent", s),
|
||||
selectList: {
|
||||
selectedPrefix: (t) => theme.fg("accent", t),
|
||||
selectedText: (t) => theme.fg("accent", t),
|
||||
description: (t) => theme.fg("muted", t),
|
||||
scrollInfo: (t) => theme.fg("dim", t),
|
||||
noMatch: (t) => theme.fg("warning", t),
|
||||
},
|
||||
};
|
||||
const editor = new Editor(tui, editorTheme);
|
||||
|
||||
function refresh() {
|
||||
cachedLines = undefined;
|
||||
tui.requestRender();
|
||||
}
|
||||
|
||||
function submit(cancelled: boolean) {
|
||||
done({
|
||||
questions,
|
||||
answers: Array.from(answers.values()),
|
||||
cancelled,
|
||||
});
|
||||
}
|
||||
|
||||
function currentQuestion(): Question | undefined {
|
||||
return questions[currentTab];
|
||||
}
|
||||
|
||||
function currentOptions(): RenderOption[] {
|
||||
const q = currentQuestion();
|
||||
if (!q) return [];
|
||||
const opts: RenderOption[] = [...q.options];
|
||||
if (q.allowOther) {
|
||||
opts.push({
|
||||
value: "__other__",
|
||||
label: "Type something.",
|
||||
isOther: true,
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
function allAnswered(): boolean {
|
||||
return questions.every((q) => answers.has(q.id));
|
||||
}
|
||||
|
||||
function advanceAfterAnswer() {
|
||||
if (!isMulti) {
|
||||
submit(false);
|
||||
return;
|
||||
}
|
||||
if (currentTab < questions.length - 1) {
|
||||
currentTab++;
|
||||
} else {
|
||||
currentTab = questions.length;
|
||||
}
|
||||
optionIndex = 0;
|
||||
refresh();
|
||||
}
|
||||
|
||||
function saveAnswer(
|
||||
questionId: string,
|
||||
value: string,
|
||||
label: string,
|
||||
wasCustom: boolean,
|
||||
index?: number,
|
||||
) {
|
||||
answers.set(questionId, {
|
||||
id: questionId,
|
||||
value,
|
||||
label,
|
||||
wasCustom,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
editor.onSubmit = (value) => {
|
||||
if (!inputQuestionId) return;
|
||||
const trimmed = value.trim() || "(no response)";
|
||||
saveAnswer(inputQuestionId, trimmed, trimmed, true);
|
||||
inputMode = false;
|
||||
inputQuestionId = null;
|
||||
editor.setText("");
|
||||
advanceAfterAnswer();
|
||||
};
|
||||
|
||||
function handleInput(data: string) {
|
||||
if (inputMode) {
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
inputMode = false;
|
||||
inputQuestionId = null;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
editor.handleInput(data);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const q = currentQuestion();
|
||||
const opts = currentOptions();
|
||||
|
||||
if (isMulti) {
|
||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
||||
currentTab = (currentTab + 1) % totalTabs;
|
||||
optionIndex = 0;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
matchesKey(data, Key.shift("tab")) ||
|
||||
matchesKey(data, Key.left)
|
||||
) {
|
||||
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
|
||||
optionIndex = 0;
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTab === questions.length) {
|
||||
if (matchesKey(data, Key.enter) && allAnswered()) {
|
||||
submit(false);
|
||||
} else if (matchesKey(data, Key.escape)) {
|
||||
submit(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.up)) {
|
||||
optionIndex = Math.max(0, optionIndex - 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.enter) && q) {
|
||||
const opt = opts[optionIndex];
|
||||
if (opt.isOther) {
|
||||
inputMode = true;
|
||||
inputQuestionId = q.id;
|
||||
editor.setText("");
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
|
||||
advanceAfterAnswer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
submit(true);
|
||||
}
|
||||
}
|
||||
|
||||
function render(width: number): string[] {
|
||||
if (cachedLines) return cachedLines;
|
||||
|
||||
const lines: string[] = [];
|
||||
const q = currentQuestion();
|
||||
const opts = currentOptions();
|
||||
|
||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
||||
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
|
||||
if (isMulti) {
|
||||
const tabs: string[] = ["← "];
|
||||
for (let i = 0; i < questions.length; i++) {
|
||||
const isActive = i === currentTab;
|
||||
const isAnswered = answers.has(questions[i].id);
|
||||
const lbl = questions[i].label;
|
||||
const box = isAnswered ? "■" : "□";
|
||||
const color = isAnswered ? "success" : "muted";
|
||||
const text = ` ${box} ${lbl} `;
|
||||
const styled = isActive
|
||||
? theme.bg("selectedBg", theme.fg("text", text))
|
||||
: theme.fg(color, text);
|
||||
tabs.push(`${styled} `);
|
||||
}
|
||||
const canSubmit = allAnswered();
|
||||
const isSubmitTab = currentTab === questions.length;
|
||||
const submitText = " ✓ Submit ";
|
||||
const submitStyled = isSubmitTab
|
||||
? theme.bg("selectedBg", theme.fg("text", submitText))
|
||||
: theme.fg(canSubmit ? "success" : "dim", submitText);
|
||||
tabs.push(`${submitStyled} →`);
|
||||
add(` ${tabs.join("")}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
function renderOptions() {
|
||||
for (let i = 0; i < opts.length; i++) {
|
||||
const opt = opts[i];
|
||||
const selected = i === optionIndex;
|
||||
const isOther = opt.isOther === true;
|
||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
||||
const color = selected ? "accent" : "text";
|
||||
if (isOther && inputMode) {
|
||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
||||
} else {
|
||||
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
|
||||
}
|
||||
if (opt.description) {
|
||||
add(` ${theme.fg("muted", opt.description)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputMode && q) {
|
||||
add(theme.fg("text", ` ${q.prompt}`));
|
||||
lines.push("");
|
||||
renderOptions();
|
||||
lines.push("");
|
||||
add(theme.fg("muted", " Your answer:"));
|
||||
for (const line of editor.render(width - 2)) {
|
||||
add(` ${line}`);
|
||||
}
|
||||
lines.push("");
|
||||
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
|
||||
} else if (currentTab === questions.length) {
|
||||
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
||||
lines.push("");
|
||||
for (const question of questions) {
|
||||
const answer = answers.get(question.id);
|
||||
if (answer) {
|
||||
const prefix = answer.wasCustom ? "(wrote) " : "";
|
||||
add(
|
||||
`${theme.fg("muted", ` ${question.label}: `)}${theme.fg(
|
||||
"text",
|
||||
prefix + answer.label,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
if (allAnswered()) {
|
||||
add(theme.fg("success", " Press Enter to submit"));
|
||||
} else {
|
||||
const missing = questions
|
||||
.filter((q) => !answers.has(q.id))
|
||||
.map((q) => q.label)
|
||||
.join(", ");
|
||||
add(theme.fg("warning", ` Unanswered: ${missing}`));
|
||||
}
|
||||
} else if (q) {
|
||||
add(theme.fg("text", ` ${q.prompt}`));
|
||||
lines.push("");
|
||||
renderOptions();
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
if (!inputMode) {
|
||||
const help = isMulti
|
||||
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
|
||||
: " ↑↓ navigate • Enter select • Esc cancel";
|
||||
add(theme.fg("dim", help));
|
||||
}
|
||||
add(theme.fg("accent", "─".repeat(width)));
|
||||
|
||||
cachedLines = lines;
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
render,
|
||||
invalidate: () => {
|
||||
cachedLines = undefined;
|
||||
},
|
||||
handleInput,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
if (result.cancelled) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
||||
details: result,
|
||||
};
|
||||
}
|
||||
|
||||
const answerLines = result.answers.map((a) => {
|
||||
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
|
||||
if (a.wasCustom) {
|
||||
return `${qLabel}: user wrote: ${a.label}`;
|
||||
}
|
||||
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: answerLines.join("\n") }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const qs = (args.questions as Question[]) || [];
|
||||
const count = qs.length;
|
||||
const labels = qs.map((q) => q.label || q.id).join(", ");
|
||||
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
|
||||
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
||||
if (labels) {
|
||||
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
||||
}
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, _options, theme) {
|
||||
const details = result.details as QuestionnaireResult | undefined;
|
||||
if (!details) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
||||
}
|
||||
if (details.cancelled) {
|
||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
||||
}
|
||||
const lines = details.answers.map((a) => {
|
||||
if (a.wasCustom) {
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
|
||||
}
|
||||
const display = a.index ? `${a.index}. ${a.label}` : a.label;
|
||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
|
||||
});
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
20
packages/pi/settings-env.patch
Normal file
20
packages/pi/settings-env.patch
Normal file
@@ -0,0 +1,20 @@
|
||||
--- /tmp/settings-manager.js.orig 2026-02-18 23:29:05.967146474 +0100
|
||||
+++ /tmp/settings-manager.js 2026-02-18 23:29:12.452952321 +0100
|
||||
@@ -97,7 +97,16 @@
|
||||
/** Create a SettingsManager that loads from files */
|
||||
static create(cwd = process.cwd(), agentDir = getAgentDir()) {
|
||||
const storage = new FileSettingsStorage(cwd, agentDir);
|
||||
- return SettingsManager.fromStorage(storage);
|
||||
+ const manager = SettingsManager.fromStorage(storage);
|
||||
+ const baseFile = process.env.PI_SETTINGS_FILE;
|
||||
+ if (baseFile) {
|
||||
+ try {
|
||||
+ const baseSettings = JSON.parse(readFileSync(baseFile, "utf-8"));
|
||||
+ manager.globalSettings = deepMergeSettings(manager.globalSettings, baseSettings);
|
||||
+ manager.settings = deepMergeSettings(manager.globalSettings, manager.projectSettings);
|
||||
+ } catch (e) {}
|
||||
+ }
|
||||
+ return manager;
|
||||
}
|
||||
/** Create a SettingsManager from an arbitrary storage backend */
|
||||
static fromStorage(storage) {
|
||||
Reference in New Issue
Block a user