Compare commits
7
Commits
ce59502e97
..
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a502f58d26 | ||
|
|
a77c4c7ebe | ||
|
|
6ed267dcde | ||
|
|
c0bcbda77f | ||
|
|
fd30e53e25 | ||
|
|
b5642ec5cd | ||
|
|
cb3576c83a |
@@ -68,7 +68,6 @@
|
|||||||
"@jupyter/builder": "^1.0.0",
|
"@jupyter/builder": "^1.0.0",
|
||||||
"@jupyter/eslint-plugin": "^0.0.5",
|
"@jupyter/eslint-plugin": "^0.0.5",
|
||||||
"@jupyterlab/core-meta": "^4.6.0-beta.0",
|
"@jupyterlab/core-meta": "^4.6.0-beta.0",
|
||||||
"@jupyterlab/testing": "^4.6.1",
|
|
||||||
"@jupyterlab/testutils": "^4.0.0",
|
"@jupyterlab/testutils": "^4.0.0",
|
||||||
"@module-federation/runtime-tools": "^2.0.0",
|
"@module-federation/runtime-tools": "^2.0.0",
|
||||||
"@types/jest": "^29.2.0",
|
"@types/jest": "^29.2.0",
|
||||||
@@ -81,7 +80,6 @@
|
|||||||
"eslint-plugin-prettier": "^5.0.0",
|
"eslint-plugin-prettier": "^5.0.0",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.0.0",
|
||||||
"jest": "^29.2.0",
|
"jest": "^29.2.0",
|
||||||
"jest-junit": "^17.0.0",
|
|
||||||
"mkdirp": "^1.0.3",
|
"mkdirp": "^1.0.3",
|
||||||
"npm-run-all2": "^7.0.1",
|
"npm-run-all2": "^7.0.1",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
@@ -93,7 +91,6 @@
|
|||||||
"stylelint-config-standard": "^34.0.0",
|
"stylelint-config-standard": "^34.0.0",
|
||||||
"stylelint-csstree-validator": "^3.0.0",
|
"stylelint-csstree-validator": "^3.0.0",
|
||||||
"stylelint-prettier": "^4.0.0",
|
"stylelint-prettier": "^4.0.0",
|
||||||
"ts-jest": "^29.4.11",
|
|
||||||
"typescript": "~5.5.4",
|
"typescript": "~5.5.4",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"yjs": "^13.5.0"
|
"yjs": "^13.5.0"
|
||||||
|
|||||||
Generated
-9965
File diff suppressed because it is too large
Load Diff
@@ -95,3 +95,11 @@ before-build-python = ["jlpm clean:all"]
|
|||||||
|
|
||||||
[tool.check-wheel-contents]
|
[tool.check-wheel-contents]
|
||||||
ignore = ["W002"]
|
ignore = ["W002"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ipykernel>=7.3.0",
|
||||||
|
"jupyter-collaboration>=4.4.1",
|
||||||
|
"jupyter-mcp-server>=1.0.6",
|
||||||
|
"notebook>=7.6.1",
|
||||||
|
]
|
||||||
|
|||||||
@@ -49,9 +49,60 @@ jest.mock('@lumino/widgets', () => {
|
|||||||
|
|
||||||
jest.mock('marked', () => ({
|
jest.mock('marked', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
marked: {
|
||||||
|
// Minimal but realistic mock: produce a real <pre><code> for
|
||||||
|
// fenced code blocks (so the per-code-block toolbar
|
||||||
|
// post-processing can find them) and <h1>/<p> for headings/text.
|
||||||
|
// Not a full markdown renderer — just enough for the prompt tests.
|
||||||
|
parse: (md: string) => {
|
||||||
|
let html = '';
|
||||||
|
let inFence = false;
|
||||||
|
let fenceLang = '';
|
||||||
|
let fenceBuf: string[] = [];
|
||||||
|
const lines = md.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const open = !inFence && /^```(\w*)\s*$/.exec(line);
|
||||||
|
const close = inFence && /^```\s*$/.test(line);
|
||||||
|
if (open) {
|
||||||
|
inFence = true;
|
||||||
|
fenceLang = open[1] || '';
|
||||||
|
fenceBuf = [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (close) {
|
||||||
|
html +=
|
||||||
|
'<pre><code class="language-' +
|
||||||
|
fenceLang +
|
||||||
|
'">' +
|
||||||
|
fenceBuf.join('\n') +
|
||||||
|
'</code></pre>';
|
||||||
|
inFence = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inFence) {
|
||||||
|
fenceBuf.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^# /.test(line)) {
|
||||||
|
html += '<h1>' + line.slice(2) + '</h1>';
|
||||||
|
} else if (line.trim()) {
|
||||||
|
html += '<p>' + line + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return html || '<p>' + md + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// jsdom doesn't implement navigator.clipboard; stub it so the copy
|
||||||
|
// button's happy path can be exercised in tests.
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||||
|
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the network-touching API module so jsdom tests don't try to hit
|
// Mock the network-touching API module so jsdom tests don't try to hit
|
||||||
// a real notebook server (and to keep the refresh-history logic decoupled
|
// a real notebook server (and to keep the refresh-history logic decoupled
|
||||||
// from the network). The cell_actions tests focus on widget behavior.
|
// from the network). The cell_actions tests focus on widget behavior.
|
||||||
@@ -101,6 +152,11 @@ function makeFakeCell(
|
|||||||
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||||
(cell as any).model = model;
|
(cell as any).model = model;
|
||||||
(cell as any).node = document.createElement('div');
|
(cell as any).node = document.createElement('div');
|
||||||
|
// Minimal editor stub: cursor always at (line 0, column 0) so the
|
||||||
|
// "insert at cursor" tests produce a deterministic offset.
|
||||||
|
(cell as any).editor = {
|
||||||
|
getCursorPosition: () => ({ line: 0, column: 0 })
|
||||||
|
};
|
||||||
|
|
||||||
const notebook: any = { widgets: [] as CodeCell[] };
|
const notebook: any = { widgets: [] as CodeCell[] };
|
||||||
const panel: any = {
|
const panel: any = {
|
||||||
@@ -366,6 +422,32 @@ describe('OpenCodeCellActions', () => {
|
|||||||
// A history refresh was triggered (to pick up the new assistant msg).
|
// A history refresh was triggered (to pick up the new assistant msg).
|
||||||
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
|
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clears the input textarea after a successful response', () => {
|
||||||
|
setOpenCodeProviders(null);
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const actions = new OpenCodeCellActions(cell);
|
||||||
|
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
|
||||||
|
(actions as any).onAfterAttach({} as any);
|
||||||
|
|
||||||
|
// Open the prompt and type something into the textarea.
|
||||||
|
const aiBtn = actions.node.querySelector('button') as HTMLButtonElement;
|
||||||
|
aiBtn.click();
|
||||||
|
const prompt = (actions as any)._prompt;
|
||||||
|
expect(prompt).not.toBeNull();
|
||||||
|
const textarea = prompt._textarea as HTMLTextAreaElement;
|
||||||
|
textarea.value = 'make it faster';
|
||||||
|
|
||||||
|
// A successful response clears the input so the user can type a
|
||||||
|
// follow-up message without manually deleting the previous prompt.
|
||||||
|
(actions as any)._handleResponse({
|
||||||
|
ok: true,
|
||||||
|
markdown: 'ok',
|
||||||
|
sessionId: 'ses-1',
|
||||||
|
notebookPath: 'foo.ipynb'
|
||||||
|
});
|
||||||
|
expect(textarea.value).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('OpenCodeInlinePrompt', () => {
|
describe('OpenCodeInlinePrompt', () => {
|
||||||
@@ -412,9 +494,184 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
// User messages are plain text (not rendered as HTML).
|
// User messages are plain text (not rendered as HTML).
|
||||||
expect(userMsg.textContent).toBe('fix the bug');
|
expect(userMsg.textContent).toBe('fix the bug');
|
||||||
expect(userMsg.innerHTML).not.toContain('<');
|
expect(userMsg.innerHTML).not.toContain('<');
|
||||||
// Assistant messages are rendered as markdown via marked (mocked).
|
// Assistant content: the marked mock produces a real <h1> + <pre>.
|
||||||
expect(asstMsg).not.toBeNull();
|
expect(asstMsg).not.toBeNull();
|
||||||
expect(asstMsg.innerHTML).toContain('<mock>');
|
expect(asstMsg.innerHTML).toContain('<h1>Here you go</h1>');
|
||||||
expect(asstMsg.innerHTML).toContain('# Here you go');
|
expect(asstMsg.innerHTML).toContain('<pre>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Copy/Insert/Replace buttons INSIDE each code block (not above the message)', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
// User message: no toolbar.
|
||||||
|
prompt.setMessages([{ role: 'user', content: 'help' }]);
|
||||||
|
expect(
|
||||||
|
prompt.node.querySelector('.opencode-msg-user .opencode-code-toolbar')
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
// Assistant with a fenced code block: toolbar inside .opencode-code-block.
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
||||||
|
]);
|
||||||
|
const asst = prompt.node.querySelector(
|
||||||
|
'.opencode-msg-assistant'
|
||||||
|
) as HTMLElement;
|
||||||
|
// The message-level toolbar must NOT exist.
|
||||||
|
expect(asst.querySelector('.opencode-msg-toolbar')).toBeNull();
|
||||||
|
// The per-code-block wrapper + toolbar.
|
||||||
|
const block = asst.querySelector('.opencode-code-block') as HTMLElement;
|
||||||
|
expect(block).not.toBeNull();
|
||||||
|
const toolbar = block.querySelector('.opencode-code-toolbar') as HTMLElement;
|
||||||
|
expect(toolbar).not.toBeNull();
|
||||||
|
const buttons = toolbar.querySelectorAll('button');
|
||||||
|
expect(buttons.length).toBe(3);
|
||||||
|
expect(buttons[0].textContent).toContain('复制');
|
||||||
|
expect(buttons[1].textContent).toContain('插入');
|
||||||
|
expect(buttons[2].textContent).toContain('替换');
|
||||||
|
// The wrapped <pre> is inside the block, below the toolbar.
|
||||||
|
expect(block.querySelector('pre')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assistant with NO code fences has no toolbar', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'assistant', content: 'no code here, just an explanation' }
|
||||||
|
]);
|
||||||
|
const asst = prompt.node.querySelector(
|
||||||
|
'.opencode-msg-assistant'
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(asst.querySelector('.opencode-code-toolbar')).toBeNull();
|
||||||
|
expect(asst.querySelector('.opencode-code-block')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Replace button overwrites the cell source with the code block content', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
||||||
|
]);
|
||||||
|
const replaceBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(3)'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
replaceBtn.click();
|
||||||
|
|
||||||
|
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
||||||
|
expect(setSource).toHaveBeenCalledTimes(1);
|
||||||
|
// The button acts on the code block's text, not the whole markdown
|
||||||
|
// (no surrounding ```fences```).
|
||||||
|
expect(setSource).toHaveBeenCalledWith('print(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Insert button splices the code block at the editor cursor (offset 0)', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
||||||
|
]);
|
||||||
|
const insertBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(2)'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
insertBtn.click();
|
||||||
|
|
||||||
|
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
||||||
|
expect(setSource).toHaveBeenCalledTimes(1);
|
||||||
|
// cursor at (0,0) with source "x = 1" -> code "print(1)" inserted at 0
|
||||||
|
expect(setSource).toHaveBeenCalledWith('print(1)x = 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Copy button writes the code block content to the clipboard', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit: jest.fn(),
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
||||||
|
]);
|
||||||
|
const copyBtn = prompt.node.querySelector(
|
||||||
|
'.opencode-msg-assistant .opencode-code-block .opencode-code-toolbar button:nth-child(1)'
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
copyBtn.click();
|
||||||
|
|
||||||
|
const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
|
||||||
|
expect(writeText).toHaveBeenCalledWith('print(1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Enter in the textarea submits; Shift+Enter does not', () => {
|
||||||
|
const cell = makeFakeCell('x = 1');
|
||||||
|
const onSubmit = jest.fn();
|
||||||
|
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||||
|
onSubmit,
|
||||||
|
onCancel: jest.fn(),
|
||||||
|
disabled: false,
|
||||||
|
providers: null
|
||||||
|
});
|
||||||
|
prompt.setMessages([
|
||||||
|
{ role: 'user', content: '' },
|
||||||
|
{ role: 'assistant', content: 'noop' }
|
||||||
|
]);
|
||||||
|
const textarea = prompt.node.querySelector(
|
||||||
|
'textarea'
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
textarea.value = 'fix the bug';
|
||||||
|
|
||||||
|
// Plain Enter submits with the current text + the current
|
||||||
|
// provider/model selection (both default to "" / first when null).
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith('fix the bug', undefined, undefined);
|
||||||
|
|
||||||
|
// Shift+Enter must NOT submit (chat convention: newline).
|
||||||
|
onSubmit.mockClear();
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
shiftKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Ctrl+Enter also must not submit (newline).
|
||||||
|
onSubmit.mockClear();
|
||||||
|
textarea.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: 'Enter',
|
||||||
|
ctrlKey: true,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
if (notebookPath) {
|
if (notebookPath) {
|
||||||
void this._refreshHistory(notebookPath);
|
void this._refreshHistory(notebookPath);
|
||||||
}
|
}
|
||||||
|
// Clear the input so the user can type a follow-up message.
|
||||||
|
this._prompt?.clearInput();
|
||||||
Notification.info(
|
Notification.info(
|
||||||
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* (the AI reply is rendered as markdown inside this history area).
|
* (the AI reply is rendered as markdown inside this history area).
|
||||||
*/
|
*/
|
||||||
import { CodeCell } from '@jupyterlab/cells';
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ function pickDefaultModelId(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeInlinePrompt extends Widget {
|
export class OpenCodeInlinePrompt extends Widget {
|
||||||
|
private _cell: CodeCell;
|
||||||
private _textarea: HTMLTextAreaElement;
|
private _textarea: HTMLTextAreaElement;
|
||||||
private _sendBtn: HTMLButtonElement;
|
private _sendBtn: HTMLButtonElement;
|
||||||
private _cancelBtn: HTMLButtonElement;
|
private _cancelBtn: HTMLButtonElement;
|
||||||
@@ -75,10 +77,11 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
private _historyEl: HTMLDivElement;
|
private _historyEl: HTMLDivElement;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
_cell: CodeCell,
|
cell: CodeCell,
|
||||||
options: IOpenCodeInlinePromptOptions
|
options: IOpenCodeInlinePromptOptions
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
this._cell = cell;
|
||||||
this.addClass('opencode-inline-prompt');
|
this.addClass('opencode-inline-prompt');
|
||||||
|
|
||||||
const flat = flattenProviders(options.providers);
|
const flat = flattenProviders(options.providers);
|
||||||
@@ -122,6 +125,14 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
const modelId = this._modelSelect?.value || undefined;
|
const modelId = this._modelSelect?.value || undefined;
|
||||||
options.onSubmit(text, providerId, modelId);
|
options.onSubmit(text, providerId, modelId);
|
||||||
});
|
});
|
||||||
|
// Chat-input convention: Enter submits, Shift+Enter / Ctrl+Enter
|
||||||
|
// insert a newline (textarea default).
|
||||||
|
this._textarea.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._sendBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
this._cancelBtn = document.createElement('button');
|
this._cancelBtn = document.createElement('button');
|
||||||
this._cancelBtn.className = 'opencode-btn-cancel';
|
this._cancelBtn.className = 'opencode-btn-cancel';
|
||||||
this._cancelBtn.textContent = '取消';
|
this._cancelBtn.textContent = '取消';
|
||||||
@@ -208,6 +219,15 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the user input textarea (called by the cell action after a
|
||||||
|
* successful response, so the user can type a follow-up message
|
||||||
|
* without manually deleting the previous prompt).
|
||||||
|
*/
|
||||||
|
clearInput(): void {
|
||||||
|
this._textarea.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the current session's messages into the scrollable history
|
* Render the current session's messages into the scrollable history
|
||||||
* area. User messages are plain text; assistant messages are rendered
|
* area. User messages are plain text; assistant messages are rendered
|
||||||
@@ -222,10 +242,146 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
wrap.textContent = msg.content;
|
wrap.textContent = msg.content;
|
||||||
} else {
|
} else {
|
||||||
wrap.innerHTML = marked.parse(msg.content) as string;
|
// Assistant: render the markdown, then for every fenced code
|
||||||
|
// block (<pre>) attach a small toolbar with Copy / Insert /
|
||||||
|
// Replace buttons that act on THAT block's code (not the whole
|
||||||
|
// message). Messages without code fences get no toolbar.
|
||||||
|
const rendered = document.createElement('div');
|
||||||
|
rendered.className = 'opencode-msg-content';
|
||||||
|
rendered.innerHTML = marked.parse(msg.content) as string;
|
||||||
|
|
||||||
|
const codeBlocks = rendered.querySelectorAll('pre');
|
||||||
|
codeBlocks.forEach(pre => {
|
||||||
|
const code = pre.querySelector('code');
|
||||||
|
if (!code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Snapshot the code text before we mutate the DOM so the
|
||||||
|
// buttons act on the exact code inside this block (no
|
||||||
|
// surrounding ```fences```, no toolbar label text).
|
||||||
|
const codeText = code.textContent ?? '';
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'opencode-code-block';
|
||||||
|
const toolbar = document.createElement('div');
|
||||||
|
toolbar.className = 'opencode-code-toolbar';
|
||||||
|
const mkBtn = (
|
||||||
|
label: string,
|
||||||
|
title: string,
|
||||||
|
onClick: () => void
|
||||||
|
) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.className = 'opencode-code-btn';
|
||||||
|
b.textContent = label;
|
||||||
|
b.title = title;
|
||||||
|
b.addEventListener('click', onClick);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
toolbar.appendChild(
|
||||||
|
mkBtn('📋 复制', '复制代码', () => {
|
||||||
|
void this._copyToClipboard(codeText);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toolbar.appendChild(
|
||||||
|
mkBtn('↪ 插入', '在光标位置插入', () => {
|
||||||
|
this._insertAtCursor(codeText);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toolbar.appendChild(
|
||||||
|
mkBtn('⟳ 替换', '替换单元格内容', () => {
|
||||||
|
this._replaceCell(codeText);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
wrap.appendChild(toolbar);
|
||||||
|
// Move the original <pre> into the wrapper.
|
||||||
|
pre.parentNode!.insertBefore(wrap, pre);
|
||||||
|
wrap.appendChild(pre);
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.appendChild(rendered);
|
||||||
}
|
}
|
||||||
this._historyEl.appendChild(wrap);
|
this._historyEl.appendChild(wrap);
|
||||||
}
|
}
|
||||||
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
this._historyEl.scrollTop = this._historyEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the given text to the clipboard. Prefers navigator.clipboard
|
||||||
|
* (available in secure contexts incl. JupyterLab). Falls back to a
|
||||||
|
* hidden textarea + execCommand for older / non-secure contexts.
|
||||||
|
*/
|
||||||
|
private async _copyToClipboard(text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
navigator.clipboard &&
|
||||||
|
typeof navigator.clipboard.writeText === 'function'
|
||||||
|
) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
Notification.info('已复制到剪贴板');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
this.node.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
ta.remove();
|
||||||
|
Notification.info('已复制到剪贴板');
|
||||||
|
} catch (e) {
|
||||||
|
Notification.error('复制失败: ' + (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert text at the current editor cursor position. If the cell has
|
||||||
|
* no editor (e.g. a non-Code cell) or the cursor can't be read, falls
|
||||||
|
* back to appending at the end of the cell.
|
||||||
|
*/
|
||||||
|
private _insertAtCursor(text: string): void {
|
||||||
|
const cell = this._cell;
|
||||||
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sharedModel = cell.model.sharedModel;
|
||||||
|
const source = sharedModel.getSource() as string;
|
||||||
|
let offset = source.length;
|
||||||
|
try {
|
||||||
|
const editor = (cell as any).editor;
|
||||||
|
const pos = editor && editor.getCursorPosition
|
||||||
|
? editor.getCursorPosition()
|
||||||
|
: null;
|
||||||
|
if (pos) {
|
||||||
|
const lines = source.split('\n');
|
||||||
|
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
|
||||||
|
const col = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(pos.column, (lines[line] ?? '').length)
|
||||||
|
);
|
||||||
|
offset = 0;
|
||||||
|
for (let i = 0; i < line; i++) {
|
||||||
|
offset += lines[i].length + 1;
|
||||||
|
}
|
||||||
|
offset += col;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to append on any error reading the cursor.
|
||||||
|
offset = source.length;
|
||||||
|
}
|
||||||
|
const newSource = source.slice(0, offset) + text + source.slice(offset);
|
||||||
|
sharedModel.setSource(newSource);
|
||||||
|
Notification.info('已插入到光标位置');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replace the entire cell source with the given text. */
|
||||||
|
private _replaceCell(text: string): void {
|
||||||
|
const cell = this._cell;
|
||||||
|
if (!cell || !cell.model || !cell.model.sharedModel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cell.model.sharedModel.setSource(text);
|
||||||
|
Notification.info('已替换单元格内容');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,50 @@
|
|||||||
color: var(--jp-ui-font-color0, #000);
|
color: var(--jp-ui-font-color0, #000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-code-block toolbar: wraps every rendered <pre> so the Copy /
|
||||||
|
Insert / Replace buttons live INSIDE the fenced section (not as a
|
||||||
|
message-level header). */
|
||||||
|
.opencode-inline-prompt .opencode-code-block {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-code-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--jp-layout-color2, #f0f0f0);
|
||||||
|
border: 1px solid var(--jp-border-color2, #ccc);
|
||||||
|
border-bottom: none;
|
||||||
|
border-top-left-radius: 3px;
|
||||||
|
border-top-right-radius: 3px;
|
||||||
|
font-size: var(--jp-ui-font-size0, 11px);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-code-btn {
|
||||||
|
border: 1px solid var(--jp-border-color2, #ccc);
|
||||||
|
background: var(--jp-layout-color1, #fff);
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--jp-ui-font-size0, 11px);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--jp-ui-font-color1, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.opencode-inline-prompt .opencode-code-btn:hover {
|
||||||
|
background: var(--jp-layout-color2, #f7f7f7);
|
||||||
|
border-color: var(--jp-border-color1, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The wrapped <pre> connects visually to the toolbar above (no top
|
||||||
|
border/radius, no top margin) so the block reads as one unit. */
|
||||||
|
.opencode-inline-prompt .opencode-code-block > pre {
|
||||||
|
margin-top: 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.opencode-inline-prompt .opencode-inline-history pre {
|
.opencode-inline-prompt .opencode-inline-history pre {
|
||||||
background: var(--jp-layout-color1, #fff);
|
background: var(--jp-layout-color1, #fff);
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"noEmitOnError": true,
|
"noEmitOnError": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"preserveWatchOutput": true,
|
"preserveWatchOutput": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"outDir": "lib",
|
"outDir": "lib",
|
||||||
|
|||||||
Reference in New Issue
Block a user