From 1c02e4df4ff0cf0a3e1dbcaac93095e9b4095ee0 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 18 Apr 2026 17:42:24 +0530 Subject: [PATCH 1/2] fix: show type filter hint and active state in TUI Discover Add 't' keybind to hotkey bar so users know the filter exists. Always display current type filter in status line, highlighted when active. --- plug/src/tui/components/hotkey-bar.jsx | 1 + plug/src/tui/components/status-line.jsx | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plug/src/tui/components/hotkey-bar.jsx b/plug/src/tui/components/hotkey-bar.jsx index 59ccb9f..75df3ea 100644 --- a/plug/src/tui/components/hotkey-bar.jsx +++ b/plug/src/tui/components/hotkey-bar.jsx @@ -9,6 +9,7 @@ const HINTS = { ['Enter', 'detail'], ['Space', 'toggle'], ['/', 'search'], + ['t', 'filter type'], ['i', 'install'], ['Esc', 'exit'], ], diff --git a/plug/src/tui/components/status-line.jsx b/plug/src/tui/components/status-line.jsx index 8392c32..2f520e1 100644 --- a/plug/src/tui/components/status-line.jsx +++ b/plug/src/tui/components/status-line.jsx @@ -17,13 +17,16 @@ export default function StatusLine({ cursor, total, filtered, isFiltered, search const position = total > 0 ? `${cursor + 1}/${filtered}` : '0/0'; const filterNote = isFiltered ? ` (filtered from ${total})` : ''; const searchHint = searchFocused ? ' · type to filter · Esc to clear' : ''; - const typeLabel = typeFilter !== 'all' ? ` · [${typeFilter}s]` : ''; + const typeLabel = typeFilter !== 'all' + ? ` · type: ${typeFilter}s` + : ' · type: all'; return ( - Discover plugins {position}{filterNote}{typeLabel}{searchHint} - + Discover plugins {position}{filterNote} + {typeLabel} + {searchHint} ); } From 1ed7e7aaba7c600465ce875103006fb13ca2071c Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 18 Apr 2026 18:11:24 +0530 Subject: [PATCH 2/2] fix: type filter navigation flicker and PAT prompt for private vaults - Memoize filteredPackages in Discover to prevent cursor reset on every render - Add token step in Add Vault flow when vault is marked private - Prompt for PAT inline when sync fails on a private vault (auth/404) - Mask token input with asterisks --- plug/src/tui/screens/discover.jsx | 11 ++- plug/src/tui/screens/vaults.jsx | 159 ++++++++++++++++++++++++++++-- 2 files changed, 157 insertions(+), 13 deletions(-) diff --git a/plug/src/tui/screens/discover.jsx b/plug/src/tui/screens/discover.jsx index 862db95..2b7621c 100644 --- a/plug/src/tui/screens/discover.jsx +++ b/plug/src/tui/screens/discover.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; import PackageList from '../components/package-list.jsx'; import SearchBox from '../components/search-box.jsx'; @@ -45,9 +45,12 @@ export default function DiscoverScreen({ onInputCapture }) { // Filtered/searched package list const searchedPackages = useSearch(packages, searchQuery); - const filteredPackages = typeFilter === 'all' - ? searchedPackages - : searchedPackages.filter(p => p.type === typeFilter); + const filteredPackages = useMemo( + () => typeFilter === 'all' + ? searchedPackages + : searchedPackages.filter(p => p.type === typeFilter), + [searchedPackages, typeFilter] + ); // Load installed package names useEffect(() => { diff --git a/plug/src/tui/screens/vaults.jsx b/plug/src/tui/screens/vaults.jsx index cff91f1..17ba8cf 100644 --- a/plug/src/tui/screens/vaults.jsx +++ b/plug/src/tui/screens/vaults.jsx @@ -9,13 +9,14 @@ import { runVaultAdd, runVaultRemove, runVaultSetDefault, + runVaultSetToken, runVaultSync, } from '../../commands/vault.js'; import { fetchRegistry } from '../../utils/registry.js'; import { getConfig } from '../../utils/config.js'; import { ctx } from '../../utils/context.js'; -/** @typedef {'list'|'confirm-remove'|'adding'|'syncing'|'result'} VaultView */ +/** @typedef {'list'|'confirm-remove'|'adding'|'syncing'|'result'|'token-prompt'} VaultView */ /** * Vaults screen — shows registered vaults and supports add/remove/sync/set-default. @@ -30,6 +31,7 @@ export default function VaultsScreen({ onInputCapture }) { const [pendingRemove, setPendingRemove] = useState(null); const [addState, setAddState] = useState(null); // { step, name, url, branch, private } const [syncResults, setSyncResults] = useState([]); + const [tokenPromptVault, setTokenPromptVault] = useState(null); const { columns: terminalWidth } = useTerminalSize(); // Notify parent when input is locked @@ -122,6 +124,7 @@ export default function VaultsScreen({ onInputCapture }) { const config = await getConfig(); const order = config.resolve_order || []; const perVault = []; + let authFailedVault = null; for (const name of order) { const v = config.vaults?.[name]; @@ -133,31 +136,64 @@ export default function VaultsScreen({ onInputCapture }) { perVault.push({ name, ok: true, packages: count }); } catch (err) { perVault.push({ name, ok: false, error: err.message }); + if (v.private && (err.code === 'AUTH_FAILED' || err.code === 'NOT_FOUND')) { + authFailedVault = name; + } } setSyncResults([...perVault]); } reload(); - setOpResult({ type: 'sync', results: perVault }); - setView('result'); + + if (authFailedVault) { + setOpResult({ type: 'sync', results: perVault }); + setTokenPromptVault(authFailedVault); + setView('token-prompt'); + } else { + setOpResult({ type: 'sync', results: perVault }); + setView('result'); + } } catch (err) { setOpResult({ type: 'sync', results: [], error: err.message }); setView('result'); } } + async function doSetToken(vaultName, token) { + setView('syncing'); + setSyncResults([]); + try { + ctx.set({ yes: true, json: true }); + await yieldToInk(); + await captureOutput(() => runVaultSetToken(vaultName, token)); + ctx.set({ yes: false, json: false }); + reload(); + setTokenPromptVault(null); + setOpResult({ type: 'set-token', name: vaultName, ok: true }); + setView('result'); + } catch (err) { + ctx.set({ yes: false, json: false }); + setTokenPromptVault(null); + setOpResult({ type: 'set-token', name: vaultName, ok: false, error: err.message }); + setView('result'); + } + } + // ── Add ──────────────────────────────────────────────────────────────────── - async function doAdd(name, url, branch, isPrivate) { + async function doAdd(name, url, branch, isPrivate, token) { setOpResult(null); - setView('syncing'); // Reuse syncing spinner for connectivity check + setView('syncing'); setSyncResults([]); try { ctx.set({ yes: true, json: true }); await yieldToInk(); await captureOutput(() => runVaultAdd(name, url, { private: isPrivate })); + if (token) { + await captureOutput(() => runVaultSetToken(name, token)); + } ctx.set({ yes: false, json: false }); reload(); setOpResult({ type: 'add', name, ok: true }); @@ -178,7 +214,7 @@ export default function VaultsScreen({ onInputCapture }) { doAdd(name, url, branch, isPrivate)} + onSubmit={(name, url, branch, isPrivate, token) => doAdd(name, url, branch, isPrivate, token)} onCancel={() => { setAddState(null); setView('list'); }} terminalWidth={terminalWidth} /> @@ -195,6 +231,17 @@ export default function VaultsScreen({ onInputCapture }) { ); } + if (view === 'token-prompt' && tokenPromptVault) { + return ( + doSetToken(tokenPromptVault, token)} + onSkip={() => { setTokenPromptVault(null); setView('result'); }} + /> + ); + } + if (view === 'syncing') { return ( @@ -417,7 +464,14 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) { setInputBuffer(''); } else if (state.step === 'private') { const isPrivate = value.toLowerCase() === 'y'; - onSubmit(state.name, state.url, state.branch, isPrivate); + if (isPrivate) { + onChange({ ...state, step: 'token', isPrivate: true }); + setInputBuffer(''); + } else { + onSubmit(state.name, state.url, state.branch, false, null); + } + } else if (state.step === 'token') { + onSubmit(state.name, state.url, state.branch, true, value || null); } } @@ -426,6 +480,7 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) { url: 'GitHub URL (e.g. https://github.com/owner/repo):', branch: `Branch [default: main]:`, private: 'Private vault? [y/N]:', + token: 'GitHub PAT (personal access token):', }; const prompt = prompts[state.step] || ''; @@ -450,17 +505,23 @@ function AddVaultForm({ state, onChange, onSubmit, onCancel, terminalWidth }) { {state.url} )} - {state.branch && state.step === 'private' && ( + {state.branch && (state.step === 'private' || state.step === 'token') && ( Branch : {state.branch} )} + {state.step === 'token' && ( + + Private: + Yes + + )} {/* Current prompt */} {prompt} - {inputBuffer} + {state.step === 'token' ? '*'.repeat(inputBuffer.length) : inputBuffer} _ @@ -517,6 +578,17 @@ function OperationResult({ result, onDone }) { )} + {result.type === 'set-token' && ( + <> + Set Token + {result.ok ? ( + ✓ Token set for vault '{result.name}' — try syncing again + ) : ( + ✗ {result.error} + )} + + )} + {result.type === 'add' && ( <> Add Vault @@ -534,3 +606,72 @@ function OperationResult({ result, onDone }) { ); } + +// ── TokenPrompt ────────────────────────────────────────────────────────────── + +function TokenPrompt({ vaultName, syncResults, onSubmit, onSkip }) { + const [inputBuffer, setInputBuffer] = useState(''); + + useInput((ch, key) => { + if (key.escape) { + onSkip(); + return; + } + + if (key.return) { + const value = inputBuffer.trim(); + if (value) { + onSubmit(value); + } else { + onSkip(); + } + return; + } + + if (key.backspace || key.delete) { + setInputBuffer((prev) => prev.slice(0, -1)); + return; + } + + if (ch && ch.length === 1 && !key.ctrl && !key.meta) { + setInputBuffer((prev) => prev + ch); + } + }); + + usePaste(useCallback((text) => { + setInputBuffer((prev) => prev + text); + }, [])); + + return ( + + {syncResults && ( + + {syncResults.map((r) => ( + + {r.ok ? ( + ✓ {r.name}: {r.packages} packages + ) : ( + ✗ {r.name}: {r.error} + )} + + ))} + + )} + + + Vault ' + {vaultName} + ' requires authentication. + + + + GitHub PAT: + {'*'.repeat(inputBuffer.length)} + _ + + + Enter to save · Esc to skip + + + ); +}