diff --git a/src/resources/extensions/async-jobs/await-tool.ts b/src/resources/extensions/async-jobs/await-tool.ts index b1b8c6214..a2300493b 100644 --- a/src/resources/extensions/async-jobs/await-tool.ts +++ b/src/resources/extensions/async-jobs/await-tool.ts @@ -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); diff --git a/src/resources/extensions/async-jobs/job-manager.ts b/src/resources/extensions/async-jobs/job-manager.ts index 174b923eb..90034b1d4 100644 --- a/src/resources/extensions/async-jobs/job-manager.ts +++ b/src/resources/extensions/async-jobs/job-manager.ts @@ -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. */ diff --git a/src/resources/extensions/bg-shell/output-formatter.ts b/src/resources/extensions/bg-shell/output-formatter.ts index eaec252af..a8d59944e 100644 --- a/src/resources/extensions/bg-shell/output-formatter.ts +++ b/src/resources/extensions/bg-shell/output-formatter.ts @@ -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 ────────────────────────────────────────────────────── diff --git a/src/resources/extensions/bg-shell/process-manager.ts b/src/resources/extensions/bg-shell/process-manager.ts index 42ff4bb3d..fcff5f374 100644 --- a/src/resources/extensions/bg-shell/process-manager.ts +++ b/src/resources/extensions/bg-shell/process-manager.ts @@ -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, diff --git a/src/resources/extensions/bg-shell/types.ts b/src/resources/extensions/bg-shell/types.ts index fa5131bd4..0047c828f 100644 --- a/src/resources/extensions/bg-shell/types.ts +++ b/src/resources/extensions/bg-shell/types.ts @@ -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; - /** 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; diff --git a/src/resources/extensions/context7/index.ts b/src/resources/extensions/context7/index.ts index 779ad5a96..2b8e60229 100644 --- a/src/resources/extensions/context7/index.ts +++ b/src/resources/extensions/context7/index.ts @@ -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) => { diff --git a/src/resources/extensions/get-secrets-from-user.ts b/src/resources/extensions/get-secrets-from-user.ts index 9cb13c7ce..f8fccdee7 100644 --- a/src/resources/extensions/get-secrets-from-user.ts +++ b/src/resources/extensions/get-secrets-from-user.ts @@ -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, `'\\''`)}'`; } diff --git a/src/resources/extensions/google-search/index.ts b/src/resources/extensions/google-search/index.ts index 3f74fc3e1..4f4f0fff6 100644 --- a/src/resources/extensions/google-search/index.ts +++ b/src/resources/extensions/google-search/index.ts @@ -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) => { diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index 93ccfdeee..6934d534a 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -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 { if (!ctx.hasUI) return null; return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { diff --git a/src/resources/extensions/shared/mod.ts b/src/resources/extensions/shared/mod.ts index c092375aa..b51e9b69f 100644 --- a/src/resources/extensions/shared/mod.ts +++ b/src/resources/extensions/shared/mod.ts @@ -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"; diff --git a/src/resources/extensions/shared/sanitize.ts b/src/resources/extensions/shared/sanitize.ts index e9aa6eec7..dbc25f2e3 100644 --- a/src/resources/extensions/shared/sanitize.ts +++ b/src/resources/extensions/shared/sanitize.ts @@ -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; +} diff --git a/src/resources/extensions/shared/wizard-ui.ts b/src/resources/extensions/shared/wizard-ui.ts deleted file mode 100644 index ed0c95163..000000000 --- a/src/resources/extensions/shared/wizard-ui.ts +++ /dev/null @@ -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> 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>; - -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; -} - -interface PageState { - selectStates: Map; - textValues: Map; - /** 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 { - const pageMap = new Map(opts.pages.map((p) => [p.id, p])); - - return ctx.ui.custom((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(); - /** 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(); - 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 { - const result: Record = {}; - 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, 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, 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, - }; - }); -} diff --git a/src/resources/extensions/subagent/index.ts b/src/resources/extensions/subagent/index.ts index ddcb833a5..46beea372 100644 --- a/src/resources/extensions/subagent/index.ts +++ b/src/resources/extensions/subagent/index.ts @@ -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 { } } -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(" ");