Pi-CLI-Web / src /piCli.js
Mike0021's picture
Deploy pi cli web docker server
aab0173 verified
import { createPiAgent, DEFAULT_LOCAL_MODEL_KEY, DEFAULT_MAX_NEW_TOKENS, LOCAL_MODELS } from "./piAgent.js";
import { PI_BUILTIN_TOOLS, PI_CLI_VERSION, PI_DEFAULT_TOOLS, PI_HELP_TEXT, PI_SLASH_COMMANDS, formatHotkeys } from "./piCliContract.js";
const ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
function stripAnsi(text) {
return String(text).replace(ANSI_PATTERN, "");
}
function textFromContent(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n");
}
function toolCallsFromMessage(message) {
return Array.isArray(message.content) ? message.content.filter((part) => part.type === "toolCall") : [];
}
function formatToolCall(call) {
const args = call.arguments || {};
switch (call.name) {
case "bash":
return `$ ${args.command || ""}${args.timeout ? ` (timeout ${args.timeout}s)` : ""}`;
case "write":
return `write ${args.path || ""} (${String(args.content || "").length} bytes)`;
case "read":
return `read ${args.path || ""}${args.offset ? `:${args.offset}` : ""}${args.limit ? ` limit=${args.limit}` : ""}`;
case "edit":
return `edit ${args.path || ""} (${Array.isArray(args.edits) ? args.edits.length : 0} replacement(s))`;
case "grep":
return `grep ${args.pattern || ""} ${args.path || "."}`;
case "find":
return `find ${args.pattern || ""} ${args.path || "."}`;
case "ls":
return `ls ${args.path || "."}`;
default:
return `${call.name} ${JSON.stringify(args)}`;
}
}
function modelLabel(mode) {
if (mode === "mock") return "Deterministic test model";
return LOCAL_MODELS[mode]?.id || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].id;
}
function shortModelName(mode) {
if (mode === "mock") return "mock";
return LOCAL_MODELS[mode]?.name || LOCAL_MODELS[DEFAULT_LOCAL_MODEL_KEY].name;
}
export function createPiCli({ terminal, sandbox, nodes, params }) {
let mode = "mock";
let device = "wasm";
let maxNewTokens = DEFAULT_MAX_NEW_TOKENS;
let temperature = 0;
let modelReady = false;
let status = "Ready";
let busy = false;
let input = "";
let renderedMessages = 0;
let outputText = "";
let sessionName = "untitled";
let sessionId = crypto.randomUUID();
let queuedLines = [];
let agent = null;
if (params.get("device")) device = params.get("device");
if (params.get("tokens")) maxNewTokens = Number(params.get("tokens")) || DEFAULT_MAX_NEW_TOKENS;
if (params.get("max_new_tokens")) maxNewTokens = Number(params.get("max_new_tokens")) || DEFAULT_MAX_NEW_TOKENS;
if (LOCAL_MODELS[params.get("model")]) mode = params.get("model");
if (LOCAL_MODELS[params.get("mode")]) mode = params.get("mode");
if (params.get("mode") === "mock") mode = "mock";
if (mode !== "mock" && !LOCAL_MODELS[mode]) mode = DEFAULT_LOCAL_MODEL_KEY;
if (!navigator.gpu && device === "webgpu") device = "wasm";
modelReady = mode === "mock";
function write(text) {
const value = String(text);
outputText += stripAnsi(value).replace(/\r/g, "");
if (outputText.length > 250000) outputText = outputText.slice(-200000);
terminal.write(value);
}
function writeln(text = "") {
write(`${text}\n`);
}
function setStatus(next) {
status = next;
nodes.status.textContent = next;
}
function setModelStatus(next) {
nodes.modelStatus.textContent = next;
nodes.modelLabel.textContent = modelLabel(mode);
}
function setSandboxStatus(next) {
nodes.sandboxStatus.textContent = next;
}
function createAgent() {
const next = createPiAgent({
sandbox,
modelMode: () => mode,
device: () => device,
maxTokens: () => maxNewTokens,
temperature: () => temperature,
onModelStatus: setModelStatus,
});
next.subscribe((event) => {
switch (event.type) {
case "agent_start":
setStatus("Agent running");
break;
case "tool_execution_start":
writeln(`\x1b[2m[running ${event.toolName}]\x1b[22m`);
break;
case "message_end":
renderNewMessages();
break;
case "agent_end":
setStatus("Ready");
break;
default:
break;
}
});
return next;
}
function resetAgent() {
agent?.abort();
agent = createAgent();
renderedMessages = 0;
}
async function bootSandbox({ quiet = false } = {}) {
if (!quiet) writeln("\x1b[2mBooting WebContainer sandbox...\x1b[22m");
await sandbox.boot();
if (!quiet) writeln("\x1b[2mSandbox ready.\x1b[22m");
}
function showGate(message = "Ready.") {
nodes.gateStatus.textContent = message;
nodes.modelGate.classList.remove("hidden");
}
function hideGate() {
nodes.modelGate.classList.add("hidden");
}
async function loadModel() {
if (mode === "mock") {
modelReady = true;
setModelStatus("Deterministic test model");
hideGate();
return;
}
busy = true;
setStatus("Loading model");
nodes.gateStatus.textContent = "Booting sandbox...";
writeln(`\x1b[2mPreparing ${modelLabel(mode)} on ${device}...\x1b[22m`);
try {
await bootSandbox({ quiet: true });
nodes.gateStatus.textContent = "Downloading model...";
await agent.preloadModel();
modelReady = true;
setModelStatus("Model ready");
nodes.gateStatus.textContent = "Model ready.";
hideGate();
writeln("\x1b[32mModel ready.\x1b[0m");
} catch (error) {
const message = error.stack || error.message || String(error);
setModelStatus("Model error");
nodes.gateStatus.textContent = message;
writeln(`\x1b[31m${message}\x1b[0m`);
} finally {
busy = false;
setStatus("Ready");
writePrompt();
}
}
function writeStartup() {
writeln(`pi - AI coding assistant with read, bash, edit, write tools`);
writeln(`version ${PI_CLI_VERSION} | web terminal ${terminal.engine} | cwd /workspace`);
writeln(`shortcuts: /hotkeys | /model | /settings | /session | /help`);
writeln(`loaded AGENTS.md: none | prompt templates: none | skills: none | extensions: none`);
writeln(`tools: ${PI_DEFAULT_TOOLS.join(", ")} (read-only extras available: grep, find, ls)`);
if (!modelReady) {
writeln(`model: ${modelLabel(mode)} is not downloaded. Use the setup dialog or /model mock.`);
} else {
writeln(`model: ${modelLabel(mode)}`);
}
writeln();
}
function footer() {
return `cwd /workspace | session ${sessionName} | model ${shortModelName(mode)} | ${maxNewTokens} max tokens | ${status}`;
}
function writePrompt() {
if (busy) return;
writeln(`\x1b[2m${footer()}\x1b[22m`);
write("> ");
terminal.focus();
}
function renderNewMessages() {
const messages = agent.state.messages;
while (renderedMessages < messages.length) {
const message = messages[renderedMessages];
renderedMessages += 1;
if (message.role === "user") continue;
if (message.role === "toolResult") {
const text = textFromContent(message.content);
const label = message.isError ? `[tool error: ${message.toolName}]` : `[tool: ${message.toolName}]`;
writeln(`\x1b[36m${label}\x1b[0m`);
if (text) writeln(text);
writeln();
continue;
}
const toolCalls = toolCallsFromMessage(message);
if (toolCalls.length > 0) {
writeln("\x1b[35mUsing pi tools:\x1b[0m");
for (const call of toolCalls) writeln(` ${formatToolCall(call)}`);
writeln();
continue;
}
const text = textFromContent(message.content);
if (text) {
writeln("\x1b[32mpi\x1b[0m");
writeln(text);
writeln();
}
}
}
async function runBashLine(command, sendToModel) {
busy = true;
setStatus("Running command");
try {
await bootSandbox({ quiet: true });
writeln(`$ ${command}`);
const result = await sandbox.bash(command, 120);
if (result.output) writeln(result.output);
writeln(`exit ${result.exitCode}`);
if (sendToModel) {
await promptAgent(`Command output from \`${command}\`:\n${result.output || "(no output)"}\nexit ${result.exitCode}`);
}
} catch (error) {
writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
} finally {
busy = false;
setStatus("Ready");
if (!sendToModel) writePrompt();
}
}
async function promptAgent(prompt) {
if (mode !== "mock" && !modelReady) {
writeln("Download the selected local model before sending, or use /model mock.");
showGate("Download the selected model before sending, or use the test model.");
writePrompt();
return;
}
busy = true;
setStatus("Agent running");
try {
await bootSandbox({ quiet: true });
await agent.prompt(prompt);
} catch (error) {
writeln(`\x1b[31m${error.stack || error.message || String(error)}\x1b[0m`);
} finally {
busy = false;
setStatus("Ready");
const next = queuedLines.shift();
if (next) {
writeln(`\x1b[2m[dequeued] ${next}\x1b[22m`);
await handleLine(next, { fromQueue: true });
} else {
writePrompt();
}
}
}
function printModels() {
writeln("Available browser-local models:");
for (const [key, value] of Object.entries(LOCAL_MODELS)) {
writeln(` ${key.padEnd(12)} ${value.id}`);
}
writeln(" mock deterministic test model");
writeln("Use /model <key> to switch. Non-mock models run in this browser through Transformers.js.");
}
function printSession() {
writeln(`Session`);
writeln(` id: ${sessionId}`);
writeln(` name: ${sessionName}`);
writeln(` messages: ${agent.state.messages.length}`);
writeln(` cwd: /workspace`);
writeln(` storage: browser memory`);
}
async function handleSlash(line) {
const [command, ...rest] = line.trim().split(/\s+/);
const argText = rest.join(" ");
switch (command) {
case "/help":
writeln(PI_HELP_TEXT);
break;
case "/hotkeys":
writeln(formatHotkeys());
break;
case "/model":
if (!argText) {
printModels();
break;
}
if (argText === "mock" || LOCAL_MODELS[argText]) {
mode = argText;
modelReady = mode === "mock";
resetAgent();
setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
writeln(`model: ${modelLabel(mode)}`);
if (!modelReady) showGate("Download the selected model before sending.");
else hideGate();
} else {
writeln(`Unknown model: ${argText}`);
printModels();
}
break;
case "/settings":
if (argText.startsWith("device=")) {
device = argText.slice("device=".length) || device;
modelReady = mode === "mock";
resetAgent();
} else if (argText.startsWith("tokens=")) {
maxNewTokens = Number(argText.slice("tokens=".length)) || maxNewTokens;
} else if (argText.startsWith("temperature=")) {
temperature = Number(argText.slice("temperature=".length)) || 0;
}
writeln(`Settings`);
writeln(` device: ${device}`);
writeln(` max tokens: ${maxNewTokens}`);
writeln(` temperature: ${temperature}`);
writeln(` transport: browser-local Transformers.js`);
break;
case "/session":
printSession();
break;
case "/new":
resetAgent();
await sandbox.reset();
sessionId = crypto.randomUUID();
sessionName = "untitled";
writeln("Started a new browser session and reset the WebContainer workspace.");
break;
case "/name":
sessionName = argText || "untitled";
writeln(`Session name: ${sessionName}`);
break;
case "/tools":
writeln(`Default tools: ${PI_DEFAULT_TOOLS.join(", ")}`);
writeln(`Built-in tools: ${PI_BUILTIN_TOOLS.join(", ")}`);
break;
case "/clear":
terminal.clear();
outputText = "";
writeStartup();
break;
case "/login":
case "/logout":
writeln("This web port runs a local browser model, so provider login is not required. Use /model to switch local planners.");
break;
case "/resume":
case "/tree":
case "/fork":
case "/clone":
case "/compact":
case "/copy":
case "/export":
case "/share":
case "/reload":
case "/changelog":
case "/scoped-models":
writeln(`${command} is recognized from Pi CLI. In this static browser build it is represented in-memory; full filesystem-backed session management is a follow-up target.`);
break;
case "/quit":
writeln("Close this browser tab to quit the web terminal.");
break;
case "/commands":
writeln(PI_SLASH_COMMANDS.join("\n"));
break;
default:
writeln(`Unknown command: ${command}`);
writeln("Type /help or /commands.");
break;
}
}
async function handleLine(line, { fromQueue = false } = {}) {
const trimmed = line.trim();
if (!trimmed) {
writePrompt();
return;
}
if (busy && !fromQueue) {
queuedLines.push(line);
writeln(`\x1b[2m[queued] ${line}\x1b[22m`);
return;
}
if (trimmed.startsWith("/")) {
await handleSlash(trimmed);
writePrompt();
return;
}
if (trimmed.startsWith("!!")) {
await runBashLine(trimmed.slice(2).trim(), false);
return;
}
if (trimmed.startsWith("!")) {
await runBashLine(trimmed.slice(1).trim(), true);
return;
}
await promptAgent(line);
}
function redrawInput(nextInput) {
write("\r\x1b[2K> ");
input = nextInput;
write(input);
}
function handleData(data) {
for (const char of data) {
if (char === "\r") {
const submitted = input;
input = "";
writeln();
void handleLine(submitted);
} else if (char === "\u007f") {
if (input.length > 0) {
input = input.slice(0, -1);
write("\b \b");
}
} else if (char === "\x03") {
if (busy) {
agent.abort();
writeln("^C");
setStatus("Aborted");
} else if (input) {
redrawInput("");
} else {
writeln("^C");
writeln("Use /quit or close this browser tab to exit.");
writePrompt();
}
} else if (char === "\x1b") {
if (busy) agent.abort();
} else if (char >= " " && char !== "\x7f") {
input += char;
write(char);
}
}
}
nodes.confirmLoadModel.addEventListener("click", async () => {
device = nodes.gateDevice.value;
await loadModel();
});
nodes.useTestModel.addEventListener("click", () => {
mode = "mock";
modelReady = true;
resetAgent();
setModelStatus("Deterministic test model");
hideGate();
writeln("model: deterministic test model");
writePrompt();
});
resetAgent();
setStatus("Ready");
setSandboxStatus("Not booted");
setModelStatus(modelReady ? "Deterministic test model" : "Model idle");
nodes.gateDevice.value = device;
if (modelReady || params.get("setup") === "skip") hideGate();
else showGate("Ready.");
terminal.onData(handleData);
writeStartup();
writePrompt();
return {
loadModel,
handleLine,
get outputText() {
return outputText;
},
get transcript() {
return agent.state.messages
.map((message) => {
if (message.role === "toolResult") return `TOOL ${message.toolName}\n${textFromContent(message.content)}`;
return `${message.role.toUpperCase()}\n${textFromContent(message.content)}`;
})
.join("\n\n");
},
get modelReady() {
return modelReady;
},
get status() {
return status;
},
get mode() {
return mode;
},
};
}