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}
);
}
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
+
+
+ );
+}