refine: extensions elegance improvements (#1503)

* refine: R1 delete dead wizard-ui.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: R2 remove dead BgProcess fields (commandHistory, envKeys)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: R3 remove no-op acknowledgeDeliveries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: R4 remove unused lineDedup tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: R5 remove unused ProcessEvent types (output, pattern_match)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: S1 replace duplicate formatTokens with shared formatTokenCount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: R1 remove re-staged wizard-ui.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: S2 consolidate maskEditorLine into shared/sanitize

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refine: S3 add session cleanup to context7 and google-search

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-19 16:59:52 -06:00 committed by GitHub
parent 8e2827646a
commit 2ea7abcd0c
13 changed files with 61 additions and 664 deletions

View file

@ -60,7 +60,6 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti
const running = watched.filter((j) => j.status === "running");
if (running.length === 0) {
const result = formatResults(watched);
manager.acknowledgeDeliveries(watched.map((j) => j.id));
return { content: [{ type: "text", text: result }], details: undefined };
}
@ -69,7 +68,6 @@ export function createAwaitTool(getManager: () => AsyncJobManager): ToolDefiniti
// Collect all completed results (more may have finished while waiting)
const completed = watched.filter((j) => j.status !== "running");
manager.acknowledgeDeliveries(completed.map((j) => j.id));
const stillRunning = watched.filter((j) => j.status === "running");
let result = formatResults(completed);

View file

@ -148,13 +148,6 @@ export class AsyncJobManager {
return [...this.jobs.values()];
}
/**
* No-op. Retained for API compatibility with await_job tool.
*/
acknowledgeDeliveries(_jobIds: string[]): void {
// Delivery is fire-once; no retries to cancel.
}
/**
* Cleanup all timers and resources.
*/

View file

@ -22,7 +22,6 @@ import {
READINESS_PATTERNS,
BUILD_COMPLETE_PATTERNS,
TEST_RESULT_PATTERNS,
LINE_DEDUP_MAX,
} from "./types.js";
import { addEvent, pushAlert } from "./process-manager.js";
import { transitionToReady } from "./readiness-detector.js";
@ -106,22 +105,6 @@ export function analyzeLine(bg: BgProcess, line: string, stream: "stdout" | "std
}
}
// Dedup tracking — evict oldest entry when map exceeds LINE_DEDUP_MAX (LRU via Map insertion order)
bg.totalRawLines++;
const lineHash = line.trim().slice(0, 100);
const existing = bg.lineDedup.get(lineHash);
if (existing !== undefined) {
// Re-insert to update insertion order (move to tail = most recent)
bg.lineDedup.delete(lineHash);
bg.lineDedup.set(lineHash, existing + 1);
} else {
if (bg.lineDedup.size >= LINE_DEDUP_MAX) {
// Evict oldest entry (Map iteration order = insertion order = LRU at head)
const oldest = bg.lineDedup.keys().next().value;
if (oldest !== undefined) bg.lineDedup.delete(oldest);
}
bg.lineDedup.set(lineHash, 1);
}
}
// ── Digest Generation ──────────────────────────────────────────────────────

View file

@ -162,12 +162,8 @@ export function startProcess(opts: StartOptions): BgProcess {
group: opts.group || null,
lastErrorCount: 0,
lastWarningCount: 0,
commandHistory: [],
lineDedup: new Map(),
totalRawLines: 0,
stdoutLineCount: 0,
stderrLineCount: 0,
envKeys: Object.keys(opts.env || {}),
restartCount: 0,
startConfig: {
command,

View file

@ -21,9 +21,7 @@ export interface ProcessEvent {
| "recovered"
| "exited"
| "crashed"
| "output"
| "port_open"
| "pattern_match"
| "port_timeout";
timestamp: number;
detail: string;
@ -92,18 +90,10 @@ export interface BgProcess {
lastErrorCount: number;
/** Last warning count snapshot for diff detection */
lastWarningCount: number;
/** Command history for shell-type sessions */
commandHistory: string[];
/** Dedup tracker: hash → count of repeated lines (capped at LINE_DEDUP_MAX entries) */
lineDedup: Map<string, number>;
/** Total raw lines (before dedup) for token savings calc */
totalRawLines: number;
/** Tracked stdout line count (incremented in addOutputLine, avoids O(n) filter) */
stdoutLineCount: number;
/** Tracked stderr line count (incremented in addOutputLine, avoids O(n) filter) */
stderrLineCount: number;
/** Env snapshot (keys only, no values for security) */
envKeys: string[];
/** Restart count */
restartCount: number;
/** Original start config for restart */
@ -187,8 +177,6 @@ export interface ProcessManifest {
export const MAX_BUFFER_LINES = 5000;
export const MAX_EVENTS = 200;
export const DEAD_PROCESS_TTL = 10 * 60 * 1000;
/** Maximum unique entries in the per-process lineDedup Map before LRU eviction. */
export const LINE_DEDUP_MAX = 500;
export const PORT_PROBE_TIMEOUT = 500;
export const READY_POLL_INTERVAL = 250;
export const DEFAULT_READY_TIMEOUT = 30000;

View file

@ -414,6 +414,13 @@ export default function (pi: ExtensionAPI) {
},
});
// ── Session cleanup ─────────────────────────────────────────────────────
pi.on("session_shutdown", async () => {
searchCache.clear();
docCache.clear();
});
// ── Startup notification ─────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {

View file

@ -11,9 +11,9 @@ import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import type { ExtensionAPI, Theme } from "@gsd/pi-coding-agent";
import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@gsd/pi-tui";
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, wrapTextWithAnsi } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import { makeUI, type ProgressStatus } from "./shared/mod.js";
import { makeUI, maskEditorLine, type ProgressStatus } from "./shared/mod.js";
import { parseSecretsManifest, formatSecretsManifest } from "./gsd/files.js";
import { resolveMilestoneFile } from "./gsd/paths.js";
import type { SecretsManifestEntry } from "./gsd/types.js";
@ -42,39 +42,6 @@ function maskPreview(value: string): string {
return `${value.slice(0, 4)}${"*".repeat(Math.max(4, value.length - 8))}${value.slice(-4)}`;
}
/**
* Replace editor visible text with masked characters while preserving ANSI cursor/sequencer codes.
*/
function maskEditorLine(line: string): string {
// Keep border / metadata lines readable.
if (line.startsWith("─")) {
return line;
}
let output = "";
let i = 0;
while (i < line.length) {
if (line.startsWith(CURSOR_MARKER, i)) {
output += CURSOR_MARKER;
i += CURSOR_MARKER.length;
continue;
}
const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
if (ansiMatch) {
output += ansiMatch[0];
i += ansiMatch[0].length;
continue;
}
const ch = line[i] as string;
output += ch === " " ? " " : "*";
i += 1;
}
return output;
}
function shellEscapeSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}

View file

@ -411,6 +411,13 @@ export default function (pi: ExtensionAPI) {
},
});
// ── Session cleanup ─────────────────────────────────────────────────────
pi.on("session_shutdown", async () => {
resultCache.clear();
client = null;
});
// ── Startup notification ─────────────────────────────────────────────────
pi.on("session_start", async (_event, ctx) => {

View file

@ -4,12 +4,12 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { AuthStorage } from "@gsd/pi-coding-agent";
import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@gsd/pi-tui";
import { Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@gsd/pi-tui";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js";
import { getRemoteConfigStatus, isValidChannelId, resolveRemoteConfig } from "./config.js";
import { sanitizeError } from "../shared/sanitize.js";
import { maskEditorLine, sanitizeError } from "../shared/mod.js";
import { getLatestPromptSummary } from "./status.js";
export async function handleRemote(
@ -353,27 +353,6 @@ function removeRemoteQuestionsConfig(): void {
writeFileSync(prefsPath, next, "utf-8");
}
function maskEditorLine(line: string): string {
let output = "";
let i = 0;
while (i < line.length) {
if (line.startsWith(CURSOR_MARKER, i)) {
output += CURSOR_MARKER;
i += CURSOR_MARKER.length;
continue;
}
const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
if (ansiMatch) {
output += ansiMatch[0];
i += ansiMatch[0].length;
continue;
}
output += line[i] === " " ? " " : "*";
i += 1;
}
return output;
}
async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise<string | null> {
if (!ctx.hasUI) return null;
return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {

View file

@ -28,6 +28,6 @@ export { showInterviewRound } from "./interview-ui.js";
export type { Question, QuestionOption, RoundResult } from "./interview-ui.js";
export { showNextAction } from "./next-action-ui.js";
export { showConfirm } from "./confirm-ui.js";
export { sanitizeError } from "./sanitize.js";
export { sanitizeError, maskEditorLine } from "./sanitize.js";
export { formatDateShort, truncateWithEllipsis } from "./format-utils.js";
export { splitFrontmatter, parseFrontmatterMap } from "./frontmatter.js";

View file

@ -1,7 +1,10 @@
/**
* Sanitize error messages by redacting token-like strings before surfacing.
* Also provides maskEditorLine for masking sensitive TUI editor input.
*/
import { CURSOR_MARKER } from "@gsd/pi-tui";
const TOKEN_PATTERNS = [
/xoxb-[A-Za-z0-9\-]+/g, // Slack bot tokens
/xoxp-[A-Za-z0-9\-]+/g, // Slack user tokens
@ -17,3 +20,36 @@ export function sanitizeError(msg: string): string {
}
return sanitized;
}
/**
* Replace editor visible text with masked characters while preserving
* ANSI cursor/sequencer codes. Keeps border/metadata lines readable.
*/
export function maskEditorLine(line: string): string {
if (line.startsWith("─")) {
return line;
}
let output = "";
let i = 0;
while (i < line.length) {
if (line.startsWith(CURSOR_MARKER, i)) {
output += CURSOR_MARKER;
i += CURSOR_MARKER.length;
continue;
}
const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i));
if (ansiMatch) {
output += ansiMatch[0];
i += ansiMatch[0].length;
continue;
}
const ch = line[i] as string;
output += ch === " " ? " " : "*";
i += 1;
}
return output;
}

View file

@ -1,551 +0,0 @@
/**
* General-purpose multi-page wizard UI.
*
* Supports declarative page definitions with select and text fields.
* Pages can conditionally route to different next pages based on answers.
*
* Navigation:
* go back one page (on page 1: triggers exit confirmation)
* / Enter advance to next page (or submit on last page)
* Escape triggers exit confirmation overlay
*
* Exit confirmation (shown on Escape or from page 1):
* 1. Go back dismiss and return to current page
* 2. Exit cancel the wizard, returns null to caller
*
* Returns:
* Record<pageId, Record<fieldId, string | string[]>> on completion
* null on exit/cancel
*
* Example:
*
* const result = await showWizard(ctx, {
* title: "New Project",
* pages: [
* {
* id: "mode",
* fields: [
* {
* type: "select",
* id: "start_type",
* question: "How do you want to start?",
* options: [
* { label: "Describe it", description: "Type what you want to build." },
* { label: "Provide a file", description: "Point to an existing doc." },
* ],
* },
* ],
* next: (answers) =>
* answers["mode"]?.["start_type"] === "Provide a file" ? "file_path" : null,
* },
* {
* id: "file_path",
* fields: [
* { type: "text", id: "path", label: "File path", placeholder: "/path/to/doc.md" },
* ],
* next: () => null,
* },
* ],
* });
*
* if (!result) return; // user exited
* const startType = result["mode"]["start_type"]; // "Describe it" | "Provide a file"
* const filePath = result["file_path"]?.["path"];
*/
import type { ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { type Theme } from "@gsd/pi-coding-agent";
import {
Editor,
Key,
matchesKey,
truncateToWidth,
type TUI,
} from "@gsd/pi-tui";
import { makeUI } from "./ui.js";
// ─── Public types ─────────────────────────────────────────────────────────────
export interface WizardOption {
label: string;
description: string;
}
export interface SelectField {
type: "select";
id: string;
question: string;
options: WizardOption[];
/** Allow multiple selections. Default: false. */
allowMultiple?: boolean;
}
export interface TextField {
type: "text";
id: string;
label: string;
placeholder?: string;
}
export type WizardField = SelectField | TextField;
/** Answers collected so far: pageId → fieldId → value */
export type WizardAnswers = Record<string, Record<string, string | string[]>>;
export interface WizardPage {
id: string;
/** Optional subtitle shown below the wizard title for this page. */
subtitle?: string;
fields: WizardField[];
/**
* Return the id of the next page, or null to end the wizard.
* Called with all answers collected so far when the user advances.
* If omitted, the wizard ends after this page.
*/
next?: (answers: WizardAnswers) => string | null;
}
export interface WizardOptions {
/** Title shown at the top of every page. */
title: string;
/** Ordered page definitions. Pages are navigated in order unless next() routes elsewhere. */
pages: WizardPage[];
}
// ─── Internal state ───────────────────────────────────────────────────────────
interface SelectState {
cursorIndex: number;
/** Single-select: committed option index, null if not yet chosen */
committedIndex: number | null;
/** Multi-select: which indices are checked */
checkedIndices: Set<number>;
}
interface PageState {
selectStates: Map<string, SelectState>;
textValues: Map<string, string>;
/** Which field is focused (for text fields) */
focusedFieldId: string | null;
}
// ─── Main export ──────────────────────────────────────────────────────────────
/**
* Show a multi-page wizard and return collected answers, or null if the user exits.
*/
export async function showWizard(
ctx: ExtensionCommandContext,
opts: WizardOptions,
): Promise<WizardAnswers | null> {
const pageMap = new Map<string, WizardPage>(opts.pages.map((p) => [p.id, p]));
return ctx.ui.custom<WizardAnswers | null>((tui: TUI, theme: Theme, _kb, done) => {
// ── State ──────────────────────────────────────────────────────────────
/** Stack of page ids visited — drives back navigation */
const pageStack: string[] = [opts.pages[0].id];
const pageStates = new Map<string, PageState>();
/** Collected answers across all pages */
const answers: WizardAnswers = {};
/** Whether the exit-confirmation overlay is showing */
let showingExitConfirm = false;
/** Cursor in the exit-confirm overlay: 0 = go back, 1 = exit */
let exitCursor = 0;
let cachedLines: string[] | undefined;
// Editors keyed by fieldId — one per text field
// editorTheme is derived from the design system at first render
const editors = new Map<string, Editor>();
let resolvedEditorTheme: import("@gsd/pi-tui").EditorTheme | null = null;
function getEditor(fieldId: string): Editor {
if (!resolvedEditorTheme) resolvedEditorTheme = makeUI(theme, 80).editorTheme;
if (!editors.has(fieldId)) editors.set(fieldId, new Editor(tui, resolvedEditorTheme));
return editors.get(fieldId)!;
}
// ── Page state helpers ─────────────────────────────────────────────────
function getPageState(pageId: string): PageState {
if (!pageStates.has(pageId)) {
pageStates.set(pageId, {
selectStates: new Map(),
textValues: new Map(),
focusedFieldId: null,
});
}
return pageStates.get(pageId)!;
}
function getSelectState(pageId: string, fieldId: string, _optCount: number): SelectState {
const ps = getPageState(pageId);
if (!ps.selectStates.has(fieldId)) {
ps.selectStates.set(fieldId, {
cursorIndex: 0,
committedIndex: null, // nothing pre-committed — user must explicitly confirm
checkedIndices: new Set(),
});
}
return ps.selectStates.get(fieldId)!;
}
// ── Current page ───────────────────────────────────────────────────────
function currentPageId(): string {
return pageStack[pageStack.length - 1];
}
function currentPage(): WizardPage {
return pageMap.get(currentPageId())!;
}
function currentPageState(): PageState {
return getPageState(currentPageId());
}
// ── Validation ─────────────────────────────────────────────────────────
function isPageComplete(page: WizardPage, ps: PageState): boolean {
for (const field of page.fields) {
if (field.type === "select") {
const ss = ps.selectStates.get(field.id);
if (!ss) return false;
if (field.allowMultiple) {
if (ss.checkedIndices.size === 0) return false;
} else {
if (ss.committedIndex === null) return false;
}
} else {
const val = ps.textValues.get(field.id) ?? "";
if (!val.trim()) return false;
}
}
return true;
}
// ── Collect answers for a page ─────────────────────────────────────────
function collectPageAnswers(page: WizardPage, ps: PageState): Record<string, string | string[]> {
const result: Record<string, string | string[]> = {};
for (const field of page.fields) {
if (field.type === "select") {
const ss = ps.selectStates.get(field.id);
if (!ss) continue;
if (field.allowMultiple) {
result[field.id] = Array.from(ss.checkedIndices)
.sort((a, b) => a - b)
.map((i) => field.options[i].label);
} else {
if (ss.committedIndex !== null && ss.committedIndex < field.options.length) {
result[field.id] = field.options[ss.committedIndex].label;
}
}
} else {
result[field.id] = ps.textValues.get(field.id) ?? "";
}
}
return result;
}
// ── Auto-focus helper ──────────────────────────────────────────────────
/** If a page's first field is a text field, focus it immediately on arrival. */
function autoFocusPageIfText(pageId: string) {
const page = pageMap.get(pageId);
if (!page) return;
const firstField = page.fields[0];
if (firstField?.type === "text") {
const ps = getPageState(pageId);
ps.focusedFieldId = firstField.id;
const editor = getEditor(firstField.id);
editor.setText(ps.textValues.get(firstField.id) ?? "");
}
}
// Auto-focus the first page if it starts with a text field
autoFocusPageIfText(opts.pages[0].id);
// ── Navigation ─────────────────────────────────────────────────────────
function advance() {
const page = currentPage();
const ps = currentPageState();
if (!isPageComplete(page, ps)) {
refresh();
return;
}
// Save text field values from editors
for (const field of page.fields) {
if (field.type === "text") {
ps.textValues.set(field.id, getEditor(field.id).getText().trim());
}
}
// Collect answers for this page
answers[page.id] = collectPageAnswers(page, ps);
// Route to next page
const nextId = page.next ? page.next(answers) : null;
if (!nextId) {
// End of wizard
done(answers);
return;
}
const nextPage = pageMap.get(nextId);
if (!nextPage) {
done(answers);
return;
}
pageStack.push(nextId);
autoFocusPageIfText(nextId);
refresh();
}
function goBack() {
if (pageStack.length <= 1) {
// Already at first page — Esc here means exit
showingExitConfirm = true;
exitCursor = 0;
refresh();
return;
}
pageStack.pop();
autoFocusPageIfText(currentPageId());
refresh();
}
function refresh() {
cachedLines = undefined;
tui.requestRender();
}
// ── Input handler ──────────────────────────────────────────────────────
function handleInput(data: string) {
// ── Exit confirm overlay ─────────────────────────────────────────
if (showingExitConfirm) {
if (matchesKey(data, Key.up)) { exitCursor = 0; refresh(); return; }
if (matchesKey(data, Key.down)) { exitCursor = 1; refresh(); return; }
if (data === "1") { showingExitConfirm = false; refresh(); return; }
if (data === "2") { done(null); return; }
if (matchesKey(data, Key.enter) || matchesKey(data, Key.space)) {
if (exitCursor === 0) { showingExitConfirm = false; refresh(); }
else { done(null); }
return;
}
// Esc on the confirm screen = go back (dismiss confirm)
if (matchesKey(data, Key.escape)) { showingExitConfirm = false; refresh(); return; }
return;
}
// ── Text field focus ─────────────────────────────────────────────
const ps = currentPageState();
if (ps.focusedFieldId) {
const editor = getEditor(ps.focusedFieldId);
if (matchesKey(data, Key.escape)) {
// First Esc: unfocus the text field
ps.textValues.set(ps.focusedFieldId, editor.getText().trim());
ps.focusedFieldId = null;
refresh();
return;
}
if (matchesKey(data, Key.enter)) {
ps.textValues.set(ps.focusedFieldId, editor.getText().trim());
ps.focusedFieldId = null;
advance();
return;
}
editor.handleInput(data);
refresh();
return;
}
// ── Esc with no text field focused: go back (or exit if on page 1) ──
if (matchesKey(data, Key.escape)) { goBack(); return; }
// ── Enter / → to advance ─────────────────────────────────────────
if (matchesKey(data, Key.enter) || matchesKey(data, Key.right)) {
// For single-select fields, commit cursor before advancing
const page = currentPage();
for (const field of page.fields) {
if (field.type === "select" && !field.allowMultiple) {
const ss = getSelectState(currentPageId(), field.id, field.options.length);
if (ss.committedIndex === null) ss.committedIndex = ss.cursorIndex;
}
}
advance();
return;
}
// ── Select field interactions ────────────────────────────────────
const page = currentPage();
for (const field of page.fields) {
if (field.type !== "select") continue;
const ss = getSelectState(currentPageId(), field.id, field.options.length);
const totalOpts = field.options.length;
if (matchesKey(data, Key.up)) {
ss.cursorIndex = (ss.cursorIndex - 1 + totalOpts) % totalOpts;
refresh(); return;
}
if (matchesKey(data, Key.down)) {
ss.cursorIndex = (ss.cursorIndex + 1) % totalOpts;
refresh(); return;
}
if (field.allowMultiple) {
if (matchesKey(data, Key.space)) {
if (ss.checkedIndices.has(ss.cursorIndex)) ss.checkedIndices.delete(ss.cursorIndex);
else ss.checkedIndices.add(ss.cursorIndex);
refresh(); return;
}
} else {
// Numeric shortcut: press the number to select and immediately advance
if (data.length === 1 && data >= "1" && data <= "9") {
const idx = parseInt(data, 10) - 1;
if (idx < totalOpts) {
ss.cursorIndex = idx;
ss.committedIndex = idx;
advance();
return;
}
}
// Enter/Space commit cursor and advance (Enter handled above, Space here)
if (matchesKey(data, Key.space)) {
ss.committedIndex = ss.cursorIndex;
advance();
return;
}
}
// Only handle the first select field for nav
break;
}
}
// ── Render ─────────────────────────────────────────────────────────────
function renderExitConfirm(width: number): string[] {
const ui = makeUI(theme, width);
const lines: string[] = [];
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
push(
ui.bar(), ui.blank(),
ui.header(" Exit wizard?"),
ui.blank(),
ui.subtitle(" Your progress will be lost."),
ui.blank(),
);
if (exitCursor === 0) push(ui.actionSelected(1, "Go back", "Return to where you were."));
else push(ui.actionUnselected(1, "Go back", "Return to where you were."));
push(ui.blank());
if (exitCursor === 1) push(ui.actionSelected(2, "Exit", "Cancel and discard all answers."));
else push(ui.actionUnselected(2, "Exit", "Cancel and discard all answers."));
push(
ui.blank(),
ui.hints(["↑/↓ to choose", "1/2 to quick-select", "enter to confirm"]),
ui.bar(),
);
return lines;
}
function renderSelectField(ui: ReturnType<typeof makeUI>, field: SelectField, lines: string[]) {
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
const ss = getSelectState(currentPageId(), field.id, field.options.length);
const multi = !!field.allowMultiple;
push(ui.question(` ${field.question}`));
if (multi) push(ui.meta(" (select all that apply — space to toggle, enter to confirm)"));
push(ui.blank());
for (let i = 0; i < field.options.length; i++) {
const opt = field.options[i];
const isCursor = i === ss.cursorIndex;
const isCommitted = i === ss.committedIndex;
if (multi) {
const isChecked = ss.checkedIndices.has(i);
if (isCursor) push(ui.checkboxSelected(opt.label, opt.description, isChecked));
else push(ui.checkboxUnselected(opt.label, opt.description, isChecked));
} else {
if (isCursor) push(ui.optionSelected(i + 1, opt.label, opt.description, isCommitted));
else push(ui.optionUnselected(i + 1, opt.label, opt.description, { isCommitted }));
}
}
}
function renderTextField(ui: ReturnType<typeof makeUI>, field: TextField, ps: PageState, lines: string[], width: number) {
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
const isFocused = ps.focusedFieldId === field.id;
const value = isFocused ? getEditor(field.id).getText() : (ps.textValues.get(field.id) ?? "");
push(ui.question(` ${field.label}`), ui.blank());
if (isFocused) {
for (const line of getEditor(field.id).render(width - 2)) lines.push(truncateToWidth(` ${line}`, width));
} else if (value) {
push(ui.answer(` ${value}`));
} else if (field.placeholder) {
push(ui.meta(` ${field.placeholder}`));
}
}
function render(width: number): string[] {
if (cachedLines) return cachedLines;
if (showingExitConfirm) { cachedLines = renderExitConfirm(width); return cachedLines; }
const ui = makeUI(theme, width);
const lines: string[] = [];
const push = (...rows: string[][]) => { for (const r of rows) lines.push(...r); };
push(ui.bar(), ui.header(` ${opts.title}`));
// ── Page indicator ────────────────────────────────────────────────
if (opts.pages.length > 1) {
push(ui.pageDots(opts.pages.length, pageStack.length - 1));
}
// ── Page content ──────────────────────────────────────────────────
const page = currentPage();
const ps = currentPageState();
if (page.subtitle) { push(ui.blank(), ui.subtitle(` ${page.subtitle}`)); }
push(ui.blank());
for (const field of page.fields) {
if (field.type === "select") renderSelectField(ui, field, lines);
else renderTextField(ui, field, ps, lines, width);
push(ui.blank());
}
// ── Footer hints ──────────────────────────────────────────────────
const isFirst = pageStack.length === 1;
const ps2 = currentPageState();
const hints: string[] = [];
if (ps2.focusedFieldId) {
hints.push("enter to continue");
hints.push("esc to unfocus");
} else {
hints.push("↑/↓ to move");
hints.push("enter to select");
hints.push(!isFirst ? "esc to go back" : "esc to exit");
}
push(ui.hints(hints), ui.bar());
cachedLines = lines;
return lines;
}
return {
render,
invalidate: () => { cachedLines = undefined; },
handleInput,
};
});
}

View file

@ -23,6 +23,7 @@ import { StringEnum } from "@gsd/pi-ai";
import { type ExtensionAPI, getMarkdownTheme } from "@gsd/pi-coding-agent";
import { Container, Markdown, Spacer, Text } from "@gsd/pi-tui";
import { Type } from "@sinclair/typebox";
import { formatTokenCount } from "../shared/mod.js";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
import {
type IsolationEnvironment,
@ -76,13 +77,6 @@ async function stopLiveSubagents(): Promise<void> {
}
}
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
function formatUsageStats(
usage: {
input: number;
@ -97,13 +91,13 @@ function formatUsageStats(
): string {
const parts: string[] = [];
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
if (usage.input) parts.push(`${formatTokens(usage.input)}`);
if (usage.output) parts.push(`${formatTokens(usage.output)}`);
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
if (usage.input) parts.push(`${formatTokenCount(usage.input)}`);
if (usage.output) parts.push(`${formatTokenCount(usage.output)}`);
if (usage.cacheRead) parts.push(`R${formatTokenCount(usage.cacheRead)}`);
if (usage.cacheWrite) parts.push(`W${formatTokenCount(usage.cacheWrite)}`);
if (usage.cost) parts.push(`$${(Number(usage.cost) || 0).toFixed(4)}`);
if (usage.contextTokens && usage.contextTokens > 0) {
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
parts.push(`ctx:${formatTokenCount(usage.contextTokens)}`);
}
if (model) parts.push(model);
return parts.join(" ");