7 Commits
Author SHA1 Message Date
tao.chen a502f58d26 chore: update dependence
CI / CI (push) Successful in 30m13s
2026-07-23 19:43:08 +08:00
tao.chenandClaude Fable 5 a77c4c7ebe feat: Enter in the inline prompt textarea submits; Shift/Ctrl+Enter newline
Chat-input convention for the inline prompt's textarea:
  - Enter (no modifier): preventDefault() + click the send button
    (which validates non-empty text and calls onSubmit with the
    current text + the selected provider/model).
  - Shift+Enter / Ctrl+Enter: do NOT preventDefault, so the textarea
    inserts a newline (the default browser behavior).

Implementation:
  - opencode_inline_prompt.ts: add a keydown listener on the textarea
    in the constructor (right after the send button setup, so
    this._sendBtn is already assigned and the closure can call
    this._sendBtn.click()).

Tests:
  - 'Enter in the textarea submits; Shift+Enter does not':
    dispatches keydown Enter on the textarea and asserts the
    onSubmit jest.fn() is called once with the textarea value and the
    current provider/model. Then dispatches Shift+Enter and
    Ctrl+Enter and asserts onSubmit is NOT called.

Verification: pytest 37/37, jest 36/36 (was 35, +1).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:39:54 +08:00
tao.chenandClaude Fable 5 6ed267dcde feat: clear the inline prompt input textarea after a successful response
After the user sends a message and the AI returns successfully, the
inline prompt's textarea is cleared so they can immediately type a
follow-up message without manually deleting the previous prompt.

This is a re-add of the clearInput feature from the reverted
fd30e53 commit, but WITHOUT the AI entry button position change (the
button stays in the native cell toolbar per c0bcbda).

Changes:
  - opencode_inline_prompt.ts: add clearInput() public method that
    sets this._textarea.value = ''.
  - opencode_cell_actions.ts: _handleResponse on a successful response
    calls this._prompt?.clearInput() after the history refresh, so
    the new conversation turn starts with a clean input.
  - test: 'clears the input textarea after a successful response'
    (types into the textarea, fires _handleResponse success, asserts
    textarea.value === '').

Verification: pytest 37/37, jest 35/35 (was 34, +1 new test).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:36:29 +08:00
tao.chen c0bcbda77f Revert "feat: AI entry button at the cell's bottom-right + clear input after success"
This reverts commit fd30e53e25.
2026-07-23 19:34:38 +08:00
tao.chenandClaude Fable 5 fd30e53e25 feat: AI entry button at the cell's bottom-right + clear input after success
AI entry button position:
  - The AI button (entry point to the inline prompt) now renders as a
    floating pill at the bottom-right corner of the cell, not inside
    the native cell toolbar.
  - The widget is still created per cell via the Cell toolbar factory
    (it's the per-cell lifecycle hook), but its own node is
    display:none inside the toolbar (no visible artifact). On
    construction, OpenCodeCellActions creates a real <button> with
    class .opencode-cell-ai-btn and appends it to cell.node. The
    cell.node gets position:relative so the absolute-positioned
    button anchors to the cell.
  - style/base.css: removed the .opencode-cell-actions / .opencode-btn
    rules (the widget's own node is no longer the visible button) and
    added .opencode-cell-ai-btn (absolute, bottom:4px, right:4px,
    z-index:10, small pill, hover/disabled states).

Clear input after success:
  - OpenCodeInlinePrompt gains a public clearInput() method that empties
    the textarea.
  - OpenCodeCellActions._handleResponse on a successful response now
    calls prompt.clearInput() after the history refresh, so the user
    can immediately type a follow-up message.

Tests:
  - All tests that previously queried the AI button on actions.node now
    query cell.node.querySelector('button.opencode-cell-ai-btn') (the
    button lives on the cell, not on the widget's own node). The two
    tests that constructed the widget only for its side effect now use
    the expression statement form to avoid the unused-const lint.
  - The orphan-cell test now sets (cell as any).node since the
    constructor accesses cell.node to position/append.
  - New test: 'clears the input textarea after a successful response'
    (types into the textarea, fires _handleResponse success, asserts
    textarea.value === '').

Verification: pytest 37/37, jest 35/35, jlpm build OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:32:45 +08:00
tao.chenandClaude Fable 5 b5642ec5cd fix: move Copy/Insert/Replace toolbar INSIDE each code block
The previous commit put the toolbar at the top of every assistant
message. The intent was actually to put the buttons inside the
fenced code blocks (the parts wrapped in ```python etc.), not above
the whole result section.

Changes:
  - opencode_inline_prompt.ts: removed the message-level toolbar on
    .opencode-msg-assistant. Instead, after marked.parse the assistant
    markdown, for every rendered <pre> we wrap it in
    .opencode-code-block and prepend a small toolbar with the same
    three buttons. Each button acts on the code text of THAT specific
    block (snapshot of <code>.textContent taken before DOM mutation,
    so the toolbar label text never leaks into the copied/inserted/
    replaced value). Messages with no code fences get no toolbar.
  - style/base.css: replaced .opencode-msg-toolbar / .opencode-msg-btn
    with .opencode-code-block / .opencode-code-toolbar /
    .opencode-code-btn. The wrapped <pre> connects visually to the
    toolbar (no top border/radius, no top margin) so each fenced
    block reads as one unit (GitHub-style).
  - Tests: updated the marked mock to produce real <pre><code> for
    fenced code (so the per-block post-processing can find them)
    and <h1>/<p> for headings. The 4 button tests now look for the
    toolbar inside .opencode-code-block and assert the button acts
    on the code block text ('print(1)'), not the whole markdown.
    Added a test that an assistant message with no code fences has
    no toolbar.

Verification: pytest 37/37, jest 34/34, build OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:19:29 +08:00
tao.chenandClaude Fable 5 cb3576c83a feat: Copy / Insert / Replace buttons on assistant messages
Each assistant message in the inline prompt history now renders a
small toolbar with three action buttons:

  📋 复制  — copy the rendered assistant content (the markdown
             source, not the rendered HTML) to the clipboard via
             navigator.clipboard.writeText, with a fallback to a
             hidden textarea + execCommand for non-secure contexts.
  ↪ 插入  — insert the content at the current editor cursor. Uses
             cell.editor.getCursorPosition() + the cell source to
             compute the offset, then sharedModel.setSource to splice
             the text in. Falls back to appending if the cursor can't
             be read.
  ⟳ 替换  — replace the entire cell source with the assistant content
             via sharedModel.setSource.

User messages and the textarea/send/cancel UI are unchanged; the
toolbar only appears on rendered assistant (markdown) messages.

Implementation:
  - OpenCodeInlinePrompt now stores the cell (private _cell) so the
    button handlers can act on it.
  - New private methods: _copyToClipboard, _insertAtCursor,
    _replaceCell. Each shows a Notification on success/failure.
  - Added import { Notification } from '@jupyterlab/apputils' (the
    prompt file didn't import it before; cell_actions does, so the
    test mock already provides the static methods).
  - style/base.css: .opencode-msg-toolbar (flex row) and
    .opencode-msg-btn (small bordered button) styles.
  - Test fake cell now has an editor stub returning cursor (0,0) so
    the Insert test produces a deterministic offset. jsdom lacks
    navigator.clipboard; the test stubs it globally.

Tests:
  - jest 33/33 (was 29; +4 new: toolbar renders 3 buttons only on
    assistant, Replace sets source, Insert splices at offset 0, Copy
    calls clipboard.writeText with the content).
  - pytest 37/37 (no server changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:13:28 +08:00
10 changed files with 5480 additions and 13320 deletions
-3
View File
@@ -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"
-9965
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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",
]
+261 -4
View File
@@ -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();
}); });
}); });
+2
View File
@@ -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)}`
); );
+158 -2
View File
@@ -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('已替换单元格内容');
}
} }
+44
View File
@@ -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;
+5 -4
View File
@@ -8,10 +8,11 @@
"jsx": "react", "jsx": "react",
"lib": ["DOM", "ES2018", "ES2020.Intl"], "lib": ["DOM", "ES2018", "ES2020.Intl"],
"module": "esnext", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"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",
Generated
+1677
View File
File diff suppressed because it is too large Load Diff
+3325 -3342
View File
File diff suppressed because it is too large Load Diff