mirror of
https://github.com/multipleof4/dsh-context-plugin.git
synced 2026-09-18 19:45:44 +00:00
feat: insert workspace context into composer draft
This commit is contained in:
168
src/client.js
168
src/client.js
@@ -4,15 +4,14 @@
|
|||||||
* Registers:
|
* Registers:
|
||||||
* 1. An interactive floating UI window in `shell.overlay`
|
* 1. An interactive floating UI window in `shell.overlay`
|
||||||
* 2. An action button in `conversation.session.header.actions`
|
* 2. An action button in `conversation.session.header.actions`
|
||||||
|
* 3. An action button in `conversation.input.left`
|
||||||
*
|
*
|
||||||
* Features:
|
* 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
|
* - Scans workspace files respecting .gitignore, .dshignore, and .git
|
||||||
* - Detects binary files and replaces content with notice
|
* - Detects binary files and replaces content with notice
|
||||||
* - Dynamically calculates code fence backticks (>= 3 backticks)
|
* - Dynamically calculates code fence backticks (>= 3 backticks)
|
||||||
* - Shows loading spinner and progress counter (Reading X/N, Injecting X/N)
|
* - Inserts all formatted files into the composer draft as a single prompt
|
||||||
* - Delivers each file as an individual prompt turn into the chat session
|
* - Leaves cursor/space at the bottom so user can immediately type their request
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function clientPlugin() {
|
function clientPlugin() {
|
||||||
@@ -23,13 +22,18 @@ function clientPlugin() {
|
|||||||
const slots = ctx.get('slots');
|
const slots = ctx.get('slots');
|
||||||
if (!slots) return;
|
if (!slots) return;
|
||||||
|
|
||||||
// Global window state emitter so Header Action Button and Overlay Window stay in sync
|
// Shared cross-slot coordinator
|
||||||
const listeners = new Set();
|
const listeners = new Set();
|
||||||
const windowState = {
|
const windowState = {
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
isMinimized: false
|
isMinimized: false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const sharedStore = {
|
||||||
|
inputActions: null,
|
||||||
|
currentDraft: ''
|
||||||
|
};
|
||||||
|
|
||||||
function updateWindowState(patch) {
|
function updateWindowState(patch) {
|
||||||
Object.assign(windowState, patch);
|
Object.assign(windowState, patch);
|
||||||
for (const cb of listeners) cb();
|
for (const cb of listeners) cb();
|
||||||
@@ -341,7 +345,7 @@ function clientPlugin() {
|
|||||||
const [scheme, setScheme] = React.useState('file'); // 'file' | 'vscode'
|
const [scheme, setScheme] = React.useState('file'); // 'file' | 'vscode'
|
||||||
const [scanResult, setScanResult] = React.useState(null);
|
const [scanResult, setScanResult] = React.useState(null);
|
||||||
const [selectedFiles, setSelectedFiles] = React.useState(new Set());
|
const [selectedFiles, setSelectedFiles] = React.useState(new Set());
|
||||||
const [phase, setPhase] = React.useState('idle'); // 'idle' | 'scanning' | 'reading' | 'injecting' | 'completed' | 'error'
|
const [phase, setPhase] = React.useState('idle'); // 'idle' | 'scanning' | 'reading' | 'completed' | 'error'
|
||||||
const [progress, setProgress] = React.useState({ current: 0, total: 0, file: '', phaseText: '' });
|
const [progress, setProgress] = React.useState({ current: 0, total: 0, file: '', phaseText: '' });
|
||||||
const [errorMessage, setErrorMessage] = React.useState('');
|
const [errorMessage, setErrorMessage] = React.useState('');
|
||||||
const [successMessage, setSuccessMessage] = React.useState('');
|
const [successMessage, setSuccessMessage] = React.useState('');
|
||||||
@@ -407,30 +411,12 @@ function clientPlugin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ingest files into chat session
|
// Ingest files into Composer draft
|
||||||
const handleIngest = async () => {
|
const handleInsertIntoComposer = async () => {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
|
|
||||||
// 1. Resolve active session
|
// 1. Scan if not already scanned
|
||||||
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 = [];
|
let targetFiles = [];
|
||||||
if (!scanResult || scanResult.files.length === 0) {
|
if (!scanResult || scanResult.files.length === 0) {
|
||||||
setPhase('scanning');
|
setPhase('scanning');
|
||||||
@@ -457,15 +443,15 @@ function clientPlugin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const total = targetFiles.length;
|
const total = targetFiles.length;
|
||||||
|
const formattedBlocks = [];
|
||||||
|
|
||||||
// 3. Process each file
|
// 2. Read each file with progress feedback
|
||||||
try {
|
try {
|
||||||
|
setPhase('reading');
|
||||||
for (let i = 0; i < total; i++) {
|
for (let i = 0; i < total; i++) {
|
||||||
const file = targetFiles[i];
|
const file = targetFiles[i];
|
||||||
const idx = i + 1;
|
const idx = i + 1;
|
||||||
|
|
||||||
// Step 3a: Read file
|
|
||||||
setPhase('reading');
|
|
||||||
setProgress({
|
setProgress({
|
||||||
current: idx,
|
current: idx,
|
||||||
total,
|
total,
|
||||||
@@ -480,39 +466,63 @@ function clientPlugin() {
|
|||||||
extension: file.extension
|
extension: file.extension
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 3b: Build header link
|
// Format header link
|
||||||
const abs = file.absolutePath.startsWith('/') ? file.absolutePath : ('/' + file.absolutePath);
|
const abs = file.absolutePath.startsWith('/') ? file.absolutePath : ('/' + file.absolutePath);
|
||||||
const headerLink = scheme === 'vscode'
|
const headerLink = scheme === 'vscode'
|
||||||
? `[\`${file.relativePath}\`](vscode://file${abs})`
|
? `[\`${file.relativePath}\`](vscode://file${abs})`
|
||||||
: `[\`${file.relativePath}\`](file://${abs})`;
|
: `[\`${file.relativePath}\`](file://${abs})`;
|
||||||
|
|
||||||
// Step 3c: Format the individual turn
|
// Dynamic code fence and content
|
||||||
const fence = readRes.fence || '```';
|
const fence = readRes.fence || '```';
|
||||||
const ext = readRes.extension || '';
|
const ext = readRes.extension || '';
|
||||||
const content = readRes.content || '';
|
const content = readRes.content || '';
|
||||||
const normalizedContent = content.endsWith('\n') ? content : (content + '\n');
|
const normalizedContent = content.endsWith('\n') ? content : (content + '\n');
|
||||||
const turnText = `${headerLink}\n\n${fence}${ext}\n${normalizedContent}${fence}`;
|
const block = `${headerLink}\n\n${fence}${ext}\n${normalizedContent}${fence}`;
|
||||||
|
formattedBlocks.push(block);
|
||||||
|
|
||||||
// Step 3d: Inject prompt turn
|
await ctx.timeout(15);
|
||||||
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
|
// 3. Assemble full prompt with extra space at the bottom for the user request
|
||||||
await ctx.timeout(100);
|
const filesBlock = formattedBlocks.join('\n\n');
|
||||||
|
const existingDraft = (sharedStore.currentDraft || '').trim();
|
||||||
|
const fullDraft = existingDraft ? `${filesBlock}\n\n${existingDraft}` : `${filesBlock}\n\n`;
|
||||||
|
|
||||||
|
// 4. Set draft via inputActions (with DOM fallback)
|
||||||
|
let draftApplied = false;
|
||||||
|
if (sharedStore.inputActions && typeof sharedStore.inputActions.setDraft === 'function') {
|
||||||
|
try {
|
||||||
|
sharedStore.inputActions.setDraft(fullDraft);
|
||||||
|
draftApplied = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('setDraft error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draftApplied && typeof document !== 'undefined') {
|
||||||
|
const textarea = document.querySelector('textarea');
|
||||||
|
if (textarea) {
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
|
||||||
|
if (setter) {
|
||||||
|
setter.call(textarea, fullDraft);
|
||||||
|
} else {
|
||||||
|
textarea.value = fullDraft;
|
||||||
|
}
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
textarea.focus();
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = fullDraft.length;
|
||||||
|
draftApplied = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPhase('completed');
|
setPhase('completed');
|
||||||
setSuccessMessage(`Successfully injected ${total} file${total > 1 ? 's' : ''} into chat session!`);
|
setSuccessMessage(`Inserted ${total} file${total > 1 ? 's' : ''} into composer! Write your request below and press Enter.`);
|
||||||
|
|
||||||
|
// Automatically minimize after 1.5 seconds so user can write their prompt
|
||||||
|
ctx.timeout(() => {
|
||||||
|
updateWindowState({ isMinimized: true });
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setErrorMessage('Ingestion error: ' + (err.message || String(err)));
|
setErrorMessage('Ingestion error: ' + (err.message || String(err)));
|
||||||
setPhase('error');
|
setPhase('error');
|
||||||
@@ -520,7 +530,6 @@ function clientPlugin() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!win.isOpen) {
|
if (!win.isOpen) {
|
||||||
// Render a compact floating pill when closed/hidden
|
|
||||||
return h('div', { className: 'dsh-ctx-overlay-container' },
|
return h('div', { className: 'dsh-ctx-overlay-container' },
|
||||||
h('button', {
|
h('button', {
|
||||||
className: 'dsh-ctx-min-badge',
|
className: 'dsh-ctx-min-badge',
|
||||||
@@ -546,7 +555,7 @@ function clientPlugin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isBusy = phase === 'scanning' || phase === 'reading' || phase === 'injecting';
|
const isBusy = phase === 'scanning' || phase === 'reading';
|
||||||
const currentPathDisplay = workspacePath || '(auto-detecting...)';
|
const currentPathDisplay = workspacePath || '(auto-detecting...)';
|
||||||
|
|
||||||
return h('div', { className: 'dsh-ctx-overlay-container' },
|
return h('div', { className: 'dsh-ctx-overlay-container' },
|
||||||
@@ -663,12 +672,12 @@ function clientPlugin() {
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Progress Bar & Status (when reading/injecting)
|
// Progress Bar & Status (when reading)
|
||||||
isBusy && h('div', { className: 'dsh-ctx-progress' },
|
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', justifyContent: 'space-between', fontSize: '11px' } },
|
||||||
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
|
||||||
h('span', { className: 'dsh-ctx-spinner' }),
|
h('span', { className: 'dsh-ctx-spinner' }),
|
||||||
h('span', { style: { fontWeight: 500 } }, progress.phaseText || 'Processing...')
|
h('span', { style: { fontWeight: 500 } }, progress.phaseText || 'Reading files...')
|
||||||
),
|
),
|
||||||
progress.total > 0 && h('span', { style: { opacity: 0.8 } },
|
progress.total > 0 && h('span', { style: { opacity: 0.8 } },
|
||||||
`${Math.round((progress.current / progress.total) * 100)}%`
|
`${Math.round((progress.current / progress.total) * 100)}%`
|
||||||
@@ -688,19 +697,19 @@ function clientPlugin() {
|
|||||||
// Success Notice
|
// Success Notice
|
||||||
successMessage && h('div', { className: 'dsh-ctx-notice-success' }, successMessage),
|
successMessage && h('div', { className: 'dsh-ctx-notice-success' }, successMessage),
|
||||||
|
|
||||||
// Primary Action Button showing workspace path
|
// Primary Action Button: Insert into Composer
|
||||||
h('button', {
|
h('button', {
|
||||||
className: 'dsh-ctx-btn dsh-ctx-btn-primary',
|
className: 'dsh-ctx-btn dsh-ctx-btn-primary',
|
||||||
disabled: isBusy || !workspacePath.trim(),
|
disabled: isBusy || !workspacePath.trim(),
|
||||||
onClick: handleIngest
|
onClick: handleInsertIntoComposer
|
||||||
},
|
},
|
||||||
isBusy ? [
|
isBusy ? [
|
||||||
h('span', { key: 'sp', className: 'dsh-ctx-spinner' }),
|
h('span', { key: 'sp', className: 'dsh-ctx-spinner' }),
|
||||||
h('span', { key: 'tx' }, progress.phaseText || 'Processing...')
|
h('span', { key: 'tx' }, progress.phaseText || 'Reading files...')
|
||||||
] : [
|
] : [
|
||||||
h('span', { key: 'ic' }, '📥'),
|
h('span', { key: 'ic' }, '📥'),
|
||||||
h('span', { key: 'lb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
h('span', { key: 'lb', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
||||||
`Ingest: ${currentPathDisplay}`
|
`Insert into Composer: ${currentPathDisplay}`
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -711,6 +720,17 @@ function clientPlugin() {
|
|||||||
|
|
||||||
// Header Action Button mounted in conversation.session.header.actions
|
// Header Action Button mounted in conversation.session.header.actions
|
||||||
function HeaderActionButton(props) {
|
function HeaderActionButton(props) {
|
||||||
|
// Capture inputActions and inputState into sharedStore
|
||||||
|
if (props.inputActions) {
|
||||||
|
sharedStore.inputActions = props.inputActions;
|
||||||
|
}
|
||||||
|
if (props.useInput) {
|
||||||
|
try {
|
||||||
|
const inputState = props.useInput(s => s);
|
||||||
|
sharedStore.currentDraft = inputState?.draft || '';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
return h('button', {
|
return h('button', {
|
||||||
className: 'dsh-ctx-btn',
|
className: 'dsh-ctx-btn',
|
||||||
style: {
|
style: {
|
||||||
@@ -728,6 +748,37 @@ function clientPlugin() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Composer Toolbar Button mounted in conversation.input.left
|
||||||
|
function ComposerToolbarButton(props) {
|
||||||
|
if (props.inputActions) {
|
||||||
|
sharedStore.inputActions = props.inputActions;
|
||||||
|
}
|
||||||
|
if (props.useInput) {
|
||||||
|
try {
|
||||||
|
const inputState = props.useInput(s => s);
|
||||||
|
sharedStore.currentDraft = inputState?.draft || '';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('button', {
|
||||||
|
className: 'dsh-ctx-btn',
|
||||||
|
style: {
|
||||||
|
padding: '3px 8px',
|
||||||
|
fontSize: '11px',
|
||||||
|
gap: '5px',
|
||||||
|
background: 'transparent',
|
||||||
|
borderColor: 'transparent'
|
||||||
|
},
|
||||||
|
title: 'Ingest workspace files into composer',
|
||||||
|
onClick: () => {
|
||||||
|
updateWindowState({ isOpen: true, isMinimized: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
h('span', null, '📁'),
|
||||||
|
h('span', null, 'Context')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Register components in Cordis slots
|
// Register components in Cordis slots
|
||||||
slots.inject('shell.overlay', () => slots.register(
|
slots.inject('shell.overlay', () => slots.register(
|
||||||
{ name: 'shell.overlay', id: 'workspace-context-overlay' },
|
{ name: 'shell.overlay', id: 'workspace-context-overlay' },
|
||||||
@@ -738,6 +789,11 @@ function clientPlugin() {
|
|||||||
{ name: 'conversation.session.header.actions', id: 'workspace-context-action', order: 15 },
|
{ name: 'conversation.session.header.actions', id: 'workspace-context-action', order: 15 },
|
||||||
HeaderActionButton
|
HeaderActionButton
|
||||||
));
|
));
|
||||||
|
|
||||||
|
slots.inject('conversation.input.left', () => slots.register(
|
||||||
|
{ name: 'conversation.input.left', id: 'workspace-context-input-btn', order: 50 },
|
||||||
|
ComposerToolbarButton
|
||||||
|
));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/host.js
21
src/host.js
@@ -229,27 +229,6 @@ function hostPlugin() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const target = await fs.resolve(absolutePath);
|
const target = await fs.resolve(absolutePath);
|
||||||
// Null-byte check on first 8KB of bytes
|
|
||||||
const bytes = await fs.readBytes(target, undefined, 8192);
|
|
||||||
let hasNull = false;
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
if (bytes[i] === 0) {
|
|
||||||
hasNull = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasNull) {
|
|
||||||
return {
|
|
||||||
relativePath,
|
|
||||||
absolutePath,
|
|
||||||
extension: extension || '',
|
|
||||||
content: '*Binary file - content not displayed.*',
|
|
||||||
isBinary: true,
|
|
||||||
fence: '```'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = await fs.readText(target);
|
const text = await fs.readText(target);
|
||||||
const fence = getBacktickFence(text);
|
const fence = getBacktickFence(text);
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user