feat: initial dsh-context-plugin implementation

This commit is contained in:
2026-09-02 13:43:59 -07:00
commit bc6d9dfa84
5 changed files with 1091 additions and 0 deletions

745
src/client.js Normal file
View File

@@ -0,0 +1,745 @@
/**
* DeepSeek Harness Context Plugin - Client Implementation
*
* Registers:
* 1. An interactive floating UI window in `shell.overlay`
* 2. An action button in `conversation.session.header.actions`
*
* Features:
* - Displays active workspace path with editable input override
* - Configurable link switch between `file:///` and `vscode://file/`
* - Scans workspace files respecting .gitignore, .dshignore, and .git
* - Detects binary files and replaces content with notice
* - Dynamically calculates code fence backticks (>= 3 backticks)
* - Shows loading spinner and progress counter (Reading X/N, Injecting X/N)
* - Delivers each file as an individual prompt turn into the chat session
*/
function clientPlugin() {
return {
inject: ['timer'],
apply(ctx) {
const h = React.createElement;
const slots = ctx.get('slots');
if (!slots) return;
// Global window state emitter so Header Action Button and Overlay Window stay in sync
const listeners = new Set();
const windowState = {
isOpen: true,
isMinimized: false
};
function updateWindowState(patch) {
Object.assign(windowState, patch);
for (const cb of listeners) cb();
}
function useSharedWindowState() {
const [state, setState] = React.useState({ ...windowState });
React.useEffect(() => {
const cb = () => setState({ ...windowState });
listeners.add(cb);
return () => listeners.delete(cb);
}, []);
return state;
}
// Inject custom CSS styling
if (typeof styles !== 'undefined' && styles.insert) {
styles.insert(`
.dsh-ctx-overlay-container {
position: fixed;
top: 60px;
right: 24px;
z-index: 10000;
pointer-events: auto;
font-family: inherit;
}
.dsh-ctx-window {
width: 470px;
max-width: calc(100vw - 48px);
background: var(--dsw-alias-bg-overlay, #1c1d22);
border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.18));
border-radius: 12px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.45);
color: var(--dsw-alias-label-primary, #f0f0f0);
overflow: hidden;
display: flex;
flex-direction: column;
animation: dshCtxFadeIn 0.2s ease-out;
}
@keyframes dshCtxFadeIn {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
.dsh-ctx-header {
padding: 10px 14px;
background: var(--dsw-alias-bg-layer-1, #23242a);
border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.1));
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.dsh-ctx-title {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.dsh-ctx-header-btn {
background: transparent;
border: none;
color: var(--dsw-alias-label-secondary, #999);
font-size: 14px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
line-height: 1;
transition: all 0.15s;
}
.dsh-ctx-header-btn:hover {
background: rgba(255,255,255,0.1);
color: var(--dsw-alias-label-primary, #fff);
}
.dsh-ctx-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
font-size: 12px;
}
.dsh-ctx-field {
display: flex;
flex-direction: column;
gap: 5px;
}
.dsh-ctx-label {
font-size: 11px;
font-weight: 500;
color: var(--dsw-alias-label-secondary, #aaa);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.dsh-ctx-input-row {
display: flex;
gap: 6px;
}
.dsh-ctx-input {
flex: 1;
background: var(--dsw-alias-bg-layer-2, #141417);
border: 1px solid var(--dsw-alias-border-l2, #333);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
font-family: monospace;
color: var(--dsw-alias-label-primary, #f0f0f0);
outline: none;
}
.dsh-ctx-input:focus {
border-color: var(--dsw-alias-brand-primary, #3b82f6);
}
.dsh-ctx-btn {
background: var(--dsw-alias-bg-layer-2, #2a2b33);
border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.15));
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-primary, #fff);
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.dsh-ctx-btn:hover:not(:disabled) {
background: var(--dsw-alias-bg-layer-1, #363842);
border-color: var(--dsw-alias-border-l1, rgba(255,255,255,0.3));
}
.dsh-ctx-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dsh-ctx-btn-primary {
background: var(--dsw-alias-brand-primary, #2563eb);
border-color: transparent;
color: #fff;
font-weight: 600;
padding: 9px 14px;
font-size: 13px;
}
.dsh-ctx-btn-primary:hover:not(:disabled) {
background: #1d4ed8;
}
.dsh-ctx-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--dsw-alias-bg-layer-2, #141417);
border: 1px solid var(--dsw-alias-border-l2, #333);
border-radius: 6px;
padding: 4px 8px;
}
.dsh-ctx-switch-pills {
display: flex;
background: var(--dsw-alias-bg-layer-1, #23242a);
border-radius: 5px;
padding: 2px;
}
.dsh-ctx-pill {
padding: 3px 10px;
font-size: 11px;
border-radius: 4px;
cursor: pointer;
border: none;
background: transparent;
color: var(--dsw-alias-label-secondary, #999);
font-weight: 500;
transition: all 0.15s;
}
.dsh-ctx-pill.active {
background: var(--dsw-alias-brand-primary, #2563eb);
color: #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
}
.dsh-ctx-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dsh-ctx-badge {
font-size: 10px;
padding: 2px 7px;
border-radius: 4px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
color: var(--dsw-alias-label-secondary, #aaa);
}
.dsh-ctx-badge.found {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.dsh-ctx-file-list {
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--dsw-alias-border-l2, #333);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-2, #141417);
padding: 4px;
}
.dsh-ctx-file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 6px;
font-size: 11px;
font-family: monospace;
border-radius: 4px;
transition: background 0.1s;
}
.dsh-ctx-file-item:hover {
background: rgba(255,255,255,0.05);
}
.dsh-ctx-file-info {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dsh-ctx-file-tag {
font-size: 9px;
padding: 1px 4px;
border-radius: 3px;
background: rgba(255,255,255,0.1);
}
.dsh-ctx-file-tag.bin {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.dsh-ctx-progress {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--dsw-alias-bg-layer-2, #141417);
border: 1px solid var(--dsw-alias-border-l2, #333);
border-radius: 6px;
padding: 8px 10px;
}
.dsh-ctx-progress-bar-bg {
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
overflow: hidden;
}
.dsh-ctx-progress-bar-fill {
height: 100%;
background: var(--dsw-alias-brand-primary, #3b82f6);
transition: width 0.15s ease;
}
.dsh-ctx-spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: #fff;
animation: dshCtxSpin 0.7s linear infinite;
}
@keyframes dshCtxSpin {
to { transform: rotate(360deg); }
}
.dsh-ctx-min-badge {
background: var(--dsw-alias-bg-overlay, #1c1d22);
border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.25));
border-radius: 20px;
padding: 6px 14px;
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 500;
color: var(--dsw-alias-label-primary, #fff);
transition: transform 0.15s;
}
.dsh-ctx-min-badge:hover {
transform: scale(1.03);
}
.dsh-ctx-notice-success {
padding: 8px 12px;
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.35);
border-radius: 6px;
color: #4ade80;
font-size: 12px;
}
.dsh-ctx-notice-error {
padding: 8px 12px;
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.35);
color: #f87171;
font-size: 12px;
}
`);
}
// UI Window Component mounted in shell.overlay
function ContextOverlayWindow(props) {
const win = useSharedWindowState();
const sessionsState = props.useSessions ? props.useSessions(s => s) : null;
const workspacesState = props.useWorkspaces ? props.useWorkspaces(s => s) : null;
const [workspacePath, setWorkspacePath] = React.useState('');
const [scheme, setScheme] = React.useState('file'); // 'file' | 'vscode'
const [scanResult, setScanResult] = React.useState(null);
const [selectedFiles, setSelectedFiles] = React.useState(new Set());
const [phase, setPhase] = React.useState('idle'); // 'idle' | 'scanning' | 'reading' | 'injecting' | 'completed' | 'error'
const [progress, setProgress] = React.useState({ current: 0, total: 0, file: '', phaseText: '' });
const [errorMessage, setErrorMessage] = React.useState('');
const [successMessage, setSuccessMessage] = React.useState('');
// Attempt automatic workspace path detection from Host and Client
React.useEffect(() => {
host.call('get-workspace-root', {}).then(res => {
if (res && res.workspaceRoot) {
setWorkspacePath(prev => prev || res.workspaceRoot);
}
}).catch(() => {});
}, []);
React.useEffect(() => {
if (!workspacePath && workspacesState?.items?.length) {
const currentSessionId = sessionsState?.current;
let found = null;
if (currentSessionId) {
found = workspacesState.items.find(w => w.sessionIds && w.sessionIds.includes(currentSessionId));
}
if (!found) found = workspacesState.items[0];
if (found && found.path) {
setWorkspacePath(found.path);
}
}
}, [workspacesState, sessionsState, workspacePath]);
// Scan files in workspace
const handleScan = React.useCallback(async () => {
setPhase('scanning');
setErrorMessage('');
setSuccessMessage('');
try {
const res = await host.call('scan-workspace', { workspacePath: workspacePath.trim() });
if (res.rootPath) {
setWorkspacePath(res.rootPath);
}
setScanResult(res);
setSelectedFiles(new Set(res.files.map(f => f.relativePath)));
setPhase('idle');
} catch (err) {
setErrorMessage('Scan failed: ' + (err.message || String(err)));
setPhase('error');
}
}, [workspacePath]);
// Toggle file selection
const toggleFile = (relPath) => {
setSelectedFiles(prev => {
const next = new Set(prev);
if (next.has(relPath)) next.delete(relPath);
else next.add(relPath);
return next;
});
};
const toggleAll = () => {
if (!scanResult) return;
if (selectedFiles.size === scanResult.files.length) {
setSelectedFiles(new Set());
} else {
setSelectedFiles(new Set(scanResult.files.map(f => f.relativePath)));
}
};
// Ingest files into chat session
const handleIngest = async () => {
setErrorMessage('');
setSuccessMessage('');
// 1. Resolve active session
let session = null;
try {
const sessionsService = ctx.get('sessions');
const currentSessionId = sessionsState?.current || props.sessionId;
if (currentSessionId && sessionsService) {
const binding = sessionsService.binding(currentSessionId);
session = binding?.session;
}
} catch (e) {
console.error('Session resolution error', e);
}
if (!session) {
setErrorMessage('No active session found. Please open a chat session before ingesting files.');
return;
}
// 2. Scan if not already scanned
let targetFiles = [];
if (!scanResult || scanResult.files.length === 0) {
setPhase('scanning');
setProgress({ current: 0, total: 0, file: '', phaseText: 'Scanning workspace files...' });
try {
const res = await host.call('scan-workspace', { workspacePath: workspacePath.trim() });
if (res.rootPath) setWorkspacePath(res.rootPath);
setScanResult(res);
targetFiles = res.files;
setSelectedFiles(new Set(res.files.map(f => f.relativePath)));
} catch (err) {
setErrorMessage('Scan failed: ' + (err.message || String(err)));
setPhase('error');
return;
}
} else {
targetFiles = scanResult.files.filter(f => selectedFiles.has(f.relativePath));
}
if (targetFiles.length === 0) {
setErrorMessage('No files selected for ingestion.');
setPhase('idle');
return;
}
const total = targetFiles.length;
// 3. Process each file
try {
for (let i = 0; i < total; i++) {
const file = targetFiles[i];
const idx = i + 1;
// Step 3a: Read file
setPhase('reading');
setProgress({
current: idx,
total,
file: file.relativePath,
phaseText: `Reading ${idx}/${total}: ${file.relativePath}`
});
const readRes = await host.call('read-file', {
absolutePath: file.absolutePath,
relativePath: file.relativePath,
isBinaryByExt: file.isBinaryByExt,
extension: file.extension
});
// Step 3b: Build header link
const abs = file.absolutePath.startsWith('/') ? file.absolutePath : ('/' + file.absolutePath);
const headerLink = scheme === 'vscode'
? `[\`${file.relativePath}\`](vscode://file${abs})`
: `[\`${file.relativePath}\`](file://${abs})`;
// Step 3c: Format the individual turn
const fence = readRes.fence || '```';
const ext = readRes.extension || '';
const content = readRes.content || '';
const normalizedContent = content.endsWith('\n') ? content : (content + '\n');
const turnText = `${headerLink}\n\n${fence}${ext}\n${normalizedContent}${fence}`;
// Step 3d: Inject prompt turn
setPhase('injecting');
setProgress({
current: idx,
total,
file: file.relativePath,
phaseText: `Injecting ${idx}/${total}: ${file.relativePath}`
});
const promptOutcome = await session.prompt([{ type: 'text', text: turnText }], 'queue');
if (!promptOutcome || !promptOutcome.ok) {
console.error('Failed prompt turn for', file.relativePath, promptOutcome);
}
// Delay to smoothly queue messages
await ctx.timeout(100);
}
setPhase('completed');
setSuccessMessage(`Successfully injected ${total} file${total > 1 ? 's' : ''} into chat session!`);
} catch (err) {
setErrorMessage('Ingestion error: ' + (err.message || String(err)));
setPhase('error');
}
};
if (!win.isOpen) {
// Render a compact floating pill when closed/hidden
return h('div', { className: 'dsh-ctx-overlay-container' },
h('button', {
className: 'dsh-ctx-min-badge',
onClick: () => updateWindowState({ isOpen: true, isMinimized: false })
},
h('span', null, '📁'),
h('span', null, 'Context Ingester')
)
);
}
if (win.isMinimized) {
return h('div', { className: 'dsh-ctx-overlay-container' },
h('div', {
className: 'dsh-ctx-min-badge',
onClick: () => updateWindowState({ isMinimized: false })
},
h('span', null, '📁'),
h('span', null, workspacePath ? workspacePath.split('/').pop() || workspacePath : 'Context Ingester'),
scanResult ? h('span', { style: { opacity: 0.7 } }, `(${scanResult.files.length} files)`) : null,
h('span', { style: { marginLeft: '6px', fontSize: '10px' } }, '▲')
)
);
}
const isBusy = phase === 'scanning' || phase === 'reading' || phase === 'injecting';
const currentPathDisplay = workspacePath || '(auto-detecting...)';
return h('div', { className: 'dsh-ctx-overlay-container' },
h('div', { className: 'dsh-ctx-window' },
// Window Header
h('div', { className: 'dsh-ctx-header' },
h('div', { className: 'dsh-ctx-title' },
h('span', null, '📁'),
h('span', null, 'Workspace Context Ingestion')
),
h('div', { style: { display: 'flex', gap: '4px' } },
h('button', {
className: 'dsh-ctx-header-btn',
title: 'Minimize',
onClick: () => updateWindowState({ isMinimized: true })
}, '—'),
h('button', {
className: 'dsh-ctx-header-btn',
title: 'Close',
onClick: () => updateWindowState({ isOpen: false })
}, '✕')
)
),
// Window Body
h('div', { className: 'dsh-ctx-body' },
// Workspace Path Row
h('div', { className: 'dsh-ctx-field' },
h('div', { className: 'dsh-ctx-label' }, 'Workspace Directory:'),
h('div', { className: 'dsh-ctx-input-row' },
h('input', {
className: 'dsh-ctx-input',
type: 'text',
value: workspacePath,
placeholder: '/path/to/workspace',
disabled: isBusy,
onChange: (e) => setWorkspacePath(e.target.value)
}),
h('button', {
className: 'dsh-ctx-btn',
disabled: isBusy || !workspacePath.trim(),
onClick: handleScan
}, phase === 'scanning' ? h('span', { className: 'dsh-ctx-spinner' }) : 'Scan')
)
),
// Header Link Protocol Switch
h('div', { className: 'dsh-ctx-switch-row' },
h('span', { style: { fontWeight: 500 } }, 'Header Link Protocol:'),
h('div', { className: 'dsh-ctx-switch-pills' },
h('button', {
className: `dsh-ctx-pill ${scheme === 'file' ? 'active' : ''}`,
disabled: isBusy,
onClick: () => setScheme('file')
}, 'file:///'),
h('button', {
className: `dsh-ctx-pill ${scheme === 'vscode' ? 'active' : ''}`,
disabled: isBusy,
onClick: () => setScheme('vscode')
}, 'vscode://file/')
)
),
// Ignore Rules Badges
h('div', { className: 'dsh-ctx-badges' },
h('span', { className: 'dsh-ctx-badge' }, '🚫 .git/ (Always ignored)'),
h('span', { className: `dsh-ctx-badge ${scanResult?.hasGitignore ? 'found' : ''}` },
scanResult?.hasGitignore ? '✓ .gitignore (Active)' : '○ .gitignore'
),
h('span', { className: `dsh-ctx-badge ${scanResult?.hasDshignore ? 'found' : ''}` },
scanResult?.hasDshignore ? '✓ .dshignore (Active)' : '○ .dshignore'
),
scanResult?.ignoredCount ? h('span', { className: 'dsh-ctx-badge' }, `${scanResult.ignoredCount} files ignored`) : null
),
// Scanned File List (if scanned)
scanResult && h('div', { className: 'dsh-ctx-field' },
h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
h('div', { className: 'dsh-ctx-label' }, `Files to Ingest (${selectedFiles.size}/${scanResult.files.length}):`),
h('button', {
className: 'dsh-ctx-header-btn',
style: { fontSize: '11px', textDecoration: 'underline' },
disabled: isBusy,
onClick: toggleAll
}, selectedFiles.size === scanResult.files.length ? 'Deselect All' : 'Select All')
),
h('div', { className: 'dsh-ctx-file-list' },
scanResult.files.map(f => {
const isSelected = selectedFiles.has(f.relativePath);
return h('div', {
key: f.relativePath,
className: 'dsh-ctx-file-item',
onClick: () => !isBusy && toggleFile(f.relativePath)
},
h('div', { className: 'dsh-ctx-file-info' },
h('input', {
type: 'checkbox',
checked: isSelected,
disabled: isBusy,
onChange: () => {}
}),
h('span', null, f.relativePath)
),
h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
h('span', { className: `dsh-ctx-file-tag ${f.isBinaryByExt ? 'bin' : ''}` },
f.isBinaryByExt ? 'binary' : (f.extension || 'txt')
),
h('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '10px' } },
f.size > 1024 ? `${(f.size / 1024).toFixed(1)}k` : `${f.size}b`
)
)
);
})
)
),
// Progress Bar & Status (when reading/injecting)
isBusy && h('div', { className: 'dsh-ctx-progress' },
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: '11px' } },
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
h('span', { className: 'dsh-ctx-spinner' }),
h('span', { style: { fontWeight: 500 } }, progress.phaseText || 'Processing...')
),
progress.total > 0 && h('span', { style: { opacity: 0.8 } },
`${Math.round((progress.current / progress.total) * 100)}%`
)
),
progress.total > 0 && h('div', { className: 'dsh-ctx-progress-bar-bg' },
h('div', {
className: 'dsh-ctx-progress-bar-fill',
style: { width: `${(progress.current / progress.total) * 100}%` }
})
)
),
// Error Notice
errorMessage && h('div', { className: 'dsh-ctx-notice-error' }, errorMessage),
// Success Notice
successMessage && h('div', { className: 'dsh-ctx-notice-success' }, successMessage),
// Primary Action Button showing workspace path
h('button', {
className: 'dsh-ctx-btn dsh-ctx-btn-primary',
disabled: isBusy || !workspacePath.trim(),
onClick: handleIngest
},
isBusy ? [
h('span', { key: 'sp', className: 'dsh-ctx-spinner' }),
h('span', { key: 'tx' }, progress.phaseText || 'Processing...')
] : [
h('span', { key: 'ic' }, '📥'),
h('span', { key: 'lb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
`Ingest: ${currentPathDisplay}`
)
]
)
)
)
);
}
// Header Action Button mounted in conversation.session.header.actions
function HeaderActionButton(props) {
return h('button', {
className: 'dsh-ctx-btn',
style: {
padding: '4px 10px',
fontSize: '12px',
gap: '6px'
},
title: 'Open Workspace Context Ingestion Window',
onClick: () => {
updateWindowState({ isOpen: true, isMinimized: false });
}
},
h('span', null, '📁'),
h('span', null, 'Context Ingester')
);
}
// Register components in Cordis slots
slots.inject('shell.overlay', () => slots.register(
{ name: 'shell.overlay', id: 'workspace-context-overlay' },
ContextOverlayWindow
));
slots.inject('conversation.session.header.actions', () => slots.register(
{ name: 'conversation.session.header.actions', id: 'workspace-context-action', order: 15 },
HeaderActionButton
));
}
};
}
module.exports = clientPlugin;