diff --git a/opencode_bridge/routes.py b/opencode_bridge/routes.py
index 726e880..941a5fc 100644
--- a/opencode_bridge/routes.py
+++ b/opencode_bridge/routes.py
@@ -48,44 +48,19 @@ def get_session_manager(handler: APIHandler) -> SessionManager:
return sm
-def _build_request_body(prompt: str, context: dict) -> dict:
- """Build full request body for OpenCode POST /session/:id/prompt_async.
+def _build_request_body(prompt: str) -> dict:
+ """Build request body for OpenCode POST /session/:id/prompt_async.
- Returns dict with 'parts' (list) and 'system' (str) keys. No mode concept:
- the LLM interprets the user's natural-language instruction, with the code
- context and the optional traceback in front of it.
+ v0.2.x: only the user's prompt is sent to the LLM. We no longer
+ auto-wrap the cell source / previous-cell / traceback in tags —
+ the user inserts whatever they want via the "📋 插入单元格内容"
+ button (or pastes manually), so the LLM context exactly matches
+ user intent. Returns dict with 'parts' and 'system' keys.
"""
- parts: list[dict] = []
-
- if context.get("previousCode"):
- parts.append({
- "type": "text",
- "text": "\n%s\n\n" % context["previousCode"],
- })
-
- error = context.get("error")
- if error:
- parts.append({
- "type": "text",
- "text": (
- "\n%s: %s\n" % (error["ename"], error["evalue"])
- + "\n".join(error.get("traceback", []))
- + "\n\n"
- ),
- })
-
- parts.append({
- "type": "text",
- "text": "| \n%s\n | \n" % (
- context.get("language", "python"),
- context["source"],
- ),
- })
-
- if prompt:
- parts.append({"type": "text", "text": "\n%s\n" % prompt})
-
- return {"parts": parts, "system": UNIFIED_SYSTEM_PROMPT}
+ return {
+ "parts": [{"type": "text", "text": prompt}],
+ "system": UNIFIED_SYSTEM_PROMPT,
+ }
def _strip_code_fence(s: str) -> str:
@@ -204,7 +179,7 @@ class EditHandler(APIHandler):
sm = get_session_manager(self)
try:
sid = await sm.get_or_create(notebook_path)
- request_body = _build_request_body(prompt, context)
+ request_body = _build_request_body(prompt)
await client.send_message_async(
sid,
request_body["parts"],
diff --git a/src/__tests__/cell_context.spec.ts b/src/__tests__/cell_context.spec.ts
deleted file mode 100644
index 5c61262..0000000
--- a/src/__tests__/cell_context.spec.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
-import type { NotebookPanel } from '@jupyterlab/notebook';
-
-import { collectErrorOutputs, extractCellContext } from '../context/cell_context';
-
-function makeMockOutputs(items: unknown[]): ICodeCellModel['outputs'] {
- return {
- get length() {
- return items.length;
- },
- get(i: number) {
- return items[i];
- },
- // Other IObservableList methods are unused in collectErrorOutputs.
- } as unknown as ICodeCellModel['outputs'];
-}
-
-function makeMockModel(overrides: {
- id?: string;
- type?: 'code' | 'markdown' | 'raw';
- source?: string;
- outputs?: unknown[];
-}): ICodeCellModel {
- return {
- id: overrides.id ?? 'cell-1',
- type: overrides.type ?? 'code',
- sharedModel: {
- getSource: () => overrides.source ?? '',
- },
- outputs: makeMockOutputs(overrides.outputs ?? []),
- } as unknown as ICodeCellModel;
-}
-
-function makeMockCell(source: string, errorOutputs: unknown[] = []): CodeCell {
- return { model: makeMockModel({ source, outputs: errorOutputs }) } as CodeCell;
-}
-
-function makeMockNotebook(cells: CodeCell[]): { widgets: CodeCell[] } {
- return { widgets: cells };
-}
-
-function makeMockPanel(
- notebook: { widgets: CodeCell[] },
- path: string
-): NotebookPanel {
- return {
- context: { path } as NotebookPanel['context'],
- content: notebook as unknown as NotebookPanel['content'],
- } as unknown as NotebookPanel;
-}
-
-describe('collectErrorOutputs', () => {
- it('returns null when there are no outputs', () => {
- const model = makeMockModel({ outputs: [] });
- expect(collectErrorOutputs(model)).toBeNull();
- });
-
- it('returns null when outputs are non-error', () => {
- const model = makeMockModel({
- outputs: [{ type: 'stream', text: 'hello' }],
- });
- expect(collectErrorOutputs(model)).toBeNull();
- });
-
- it('extracts the first error output', () => {
- const errOut = {
- type: 'error',
- ename: 'ValueError',
- evalue: 'bad value',
- traceback: ['line 1', 'line 2'],
- };
- const model = makeMockModel({ outputs: [errOut] });
- const result = collectErrorOutputs(model);
- expect(result).toEqual({
- ename: 'ValueError',
- evalue: 'bad value',
- traceback: ['line 1', 'line 2'],
- });
- });
-
- it('handles missing traceback gracefully', () => {
- const model = makeMockModel({
- outputs: [{ type: 'error', ename: 'E', evalue: 'v' }],
- });
- const result = collectErrorOutputs(model);
- expect(result?.traceback).toEqual([]);
- });
-});
-
-describe('extractCellContext', () => {
- it('returns null when cell is not in notebook', () => {
- const cell = makeMockCell('x = 1');
- const other = makeMockCell('y = 2');
- const notebook = makeMockNotebook([other]);
- const panel = makeMockPanel(notebook, 'foo.ipynb');
- expect(extractCellContext(cell, panel)).toBeNull();
- });
-
- it('extracts source, language, and indices', () => {
- const cell = makeMockCell('x = 1');
- const notebook = makeMockNotebook([cell]);
- const panel = makeMockPanel(notebook, 'foo.ipynb');
- const ctx = extractCellContext(cell, panel);
- expect(ctx).toMatchObject({
- notebookPath: 'foo.ipynb',
- cellId: 'cell-1',
- language: 'python',
- cellIndex: 0,
- totalCells: 1,
- source: 'x = 1',
- previousCode: null,
- error: null,
- });
- });
-
- it('captures the previous cell source as previousCode', () => {
- const prev = makeMockCell('import os');
- const cell = makeMockCell('os.getcwd()');
- const notebook = makeMockNotebook([prev, cell]);
- const panel = makeMockPanel(notebook, 'foo.ipynb');
- const ctx = extractCellContext(cell, panel);
- expect(ctx?.previousCode).toBe('import os');
- expect(ctx?.cellIndex).toBe(1);
- expect(ctx?.totalCells).toBe(2);
- });
-
- it('includes the error when the cell has an error output', () => {
- const cell = makeMockCell('1/0', [
- {
- type: 'error',
- ename: 'ZeroDivisionError',
- evalue: 'division by zero',
- traceback: ['Traceback...'],
- },
- ]);
- const notebook = makeMockNotebook([cell]);
- const panel = makeMockPanel(notebook, 'foo.ipynb');
- const ctx = extractCellContext(cell, panel);
- expect(ctx?.error).toEqual({
- ename: 'ZeroDivisionError',
- evalue: 'division by zero',
- traceback: ['Traceback...'],
- });
- });
-
- it('returns null when given null cell or panel', () => {
- expect(
- extractCellContext(
- null as unknown as CodeCell,
- makeMockPanel(makeMockNotebook([]), 'x.ipynb')
- )
- ).toBeNull();
- });
-});
diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts
index 597d3ce..98024c4 100644
--- a/src/__tests__/opencode_cell_actions.spec.ts
+++ b/src/__tests__/opencode_cell_actions.spec.ts
@@ -766,7 +766,7 @@ describe('OpenCodeCellActions', () => {
});
describe('OpenCodeInlinePrompt', () => {
- it('renders a textarea, a send and a cancel button (no selector when providers null)', () => {
+ it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -775,9 +775,76 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
expect(prompt.node.querySelector('textarea')).not.toBeNull();
- // 2 buttons: 发送 and 取消 (no more output-area close button; the
- // scrollable history area is the display).
- expect(prompt.node.querySelectorAll('button').length).toBe(2);
+ // 3 buttons: 发送, 取消, 📋 插入单元格内容
+ expect(prompt.node.querySelectorAll('button').length).toBe(3);
+ const insertBtn = prompt.node.querySelector(
+ '.opencode-btn-insert-cell'
+ ) as HTMLButtonElement;
+ expect(insertBtn).not.toBeNull();
+ expect(insertBtn.textContent).toContain('插入单元格内容');
+ });
+
+ it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => {
+ const cell = makeFakeCell('print("hello")\nprint("world")');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ const insertBtn = prompt.node.querySelector(
+ '.opencode-btn-insert-cell'
+ ) as HTMLButtonElement;
+ insertBtn.click();
+ const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
+ // Inserted as a fenced code block (the fake cell's type is 'code'
+ // so the fence info string is whatever the cell reports — we just
+ // assert a fence + the source content, not a specific language).
+ expect(textarea.value).toMatch(/```\w*\n/);
+ expect(textarea.value).toContain('print("hello")');
+ expect(textarea.value).toContain('print("world")');
+ expect(textarea.value.trim().endsWith('```')).toBe(true);
+ });
+
+ it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => {
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
+ textarea.value = 'Explain:'; // no trailing newline
+ const insertBtn = prompt.node.querySelector(
+ '.opencode-btn-insert-cell'
+ ) as HTMLButtonElement;
+ insertBtn.click();
+ // The original prefix is preserved at the start.
+ expect(textarea.value.startsWith('Explain:')).toBe(true);
+ // The code block was inserted; the user prompt + the code block
+ // are now both present.
+ expect(textarea.value).toContain('x = 1');
+ expect(textarea.value).toMatch(/```\w*[\s\S]*x = 1/);
+ // The cursor was moved to the end.
+ expect(textarea.selectionStart).toBe(textarea.value.length);
+ });
+
+ it('"📋 插入单元格内容" is a no-op when the cell is empty', () => {
+ const cell = makeFakeCell('');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ const insertBtn = prompt.node.querySelector(
+ '.opencode-btn-insert-cell'
+ ) as HTMLButtonElement;
+ const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement;
+ insertBtn.click();
+ // Textarea is still empty.
+ expect(textarea.value).toBe('');
});
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
@@ -1382,7 +1449,14 @@ describe('OpenCodeInlinePrompt', () => {
expect(asstBlocks[1].textContent).toBe('turn2');
});
- it('applyEvent: drops events for a different session (defensive filter)', () => {
+ it('applyEvent: text deltas pass through regardless of sessionID match (no client-side filter for content events)', () => {
+ // Regression: the SSE filter used to drop content events whose
+ // sessionID didn't match the prompt's _sessionId. After a session
+ // switch, this caused "no reply" because events for the new
+ // session came in with a sessionID the prompt wasn't tracking.
+ // Fix: only permission/question events are strict (so the user
+ // can't reply to a different session's prompt); content events
+ // (text, reasoning, tool, idle) always pass through.
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -1393,12 +1467,35 @@ describe('OpenCodeInlinePrompt', () => {
prompt.setSessionId('s1');
prompt.applyEvent({
type: 'session.next.text.delta',
- properties: { sessionID: 's2', delta: 'should be ignored' }
+ properties: { sessionID: 's2', delta: 'still rendered' }
});
const asst = prompt.node.querySelector(
- '.opencode-inline-prompt .opencode-msg-assistant'
+ '.opencode-msg-assistant'
+ ) as HTMLElement;
+ expect(asst).not.toBeNull();
+ expect(asst.textContent).toBe('still rendered');
+ });
+
+ it('applyEvent: permission.asked IS still dropped for a different session', () => {
+ // The cross-session filter is strict ONLY for permission.asked /
+ // question.asked so the user can't accidentally reply to another
+ // session's prompt.
+ const cell = makeFakeCell('x = 1');
+ const prompt = new OpenCodeInlinePrompt(cell, {
+ onSubmit: jest.fn(),
+ onCancel: jest.fn(),
+ disabled: false,
+ providers: null
+ });
+ prompt.setSessionId('s1');
+ prompt.applyEvent({
+ type: 'permission.asked',
+ properties: { sessionID: 's2', id: 'perm-X', description: 'rm -rf' }
+ });
+ const block = prompt.node.querySelector(
+ '.opencode-msg-block-interaction'
);
- expect(asst).toBeNull();
+ expect(block).toBeNull();
});
it('applyEvent: permission.asked creates a permission block with 3 buttons', () => {
diff --git a/src/__tests__/opencode_client.spec.ts b/src/__tests__/opencode_client.spec.ts
index f77dc40..a7ed6d3 100644
--- a/src/__tests__/opencode_client.spec.ts
+++ b/src/__tests__/opencode_client.spec.ts
@@ -72,15 +72,8 @@ function mockResponse(overrides: {
const sampleRequest: OpenCodeRequest = {
prompt: 'add type hints',
context: {
- notebookPath: 'foo.ipynb',
- cellId: 'cell-1',
- language: 'python',
- cellIndex: 0,
- totalCells: 1,
- source: 'def foo(x): return x',
- previousCode: null,
- error: null,
- },
+ notebookPath: 'foo.ipynb'
+ }
};
describe('callOpenCodeEdit', () => {
diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts
index 321fddf..6eeaa8a 100644
--- a/src/components/opencode_cell_actions.ts
+++ b/src/components/opencode_cell_actions.ts
@@ -18,7 +18,6 @@
*/
import type { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
-import type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
@@ -33,11 +32,7 @@ import {
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
-import { extractCellContext } from '../context/cell_context';
-import type {
- CellContext,
- OpenCodeProvidersResponse
-} from '../types';
+import type { OpenCodeProvidersResponse } from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
@@ -61,7 +56,11 @@ type Status = 'idle' | 'loading';
export class OpenCodeCellActions extends Widget {
private _cell: CodeCell;
- private _context: CellContext | null = null;
+ // The cell's notebookPath is the only context we need for the
+ // /edit request. The cell source, error traceback, and previous
+ // cell are no longer auto-injected — the user attaches whatever
+ // they want via the "📋 插入单元格内容" button.
+ private _notebookPath: string | null = null;
private _status: Status = 'idle';
private _prompt: OpenCodeInlinePrompt | null = null;
private _sseSub: OpenCodeEventSubscription | null = null;
@@ -88,9 +87,9 @@ export class OpenCodeCellActions extends Widget {
}
private _onModelChange(): void {
- this._context = extractCellContextFromCell(this._cell);
+ this._notebookPath = extractNotebookPathFromCell(this._cell);
if (this._prompt) {
- this._prompt.setDisabled(!this._context || this._status === 'loading');
+ this._prompt.setDisabled(!this._notebookPath || this._status === 'loading');
} else {
this._render();
}
@@ -100,7 +99,7 @@ export class OpenCodeCellActions extends Widget {
const node = this.node;
node.textContent = '';
- const baseDisabled = !this._context || this._status === 'loading';
+ const baseDisabled = !this._notebookPath || this._status === 'loading';
const btn = document.createElement('button');
btn.className = 'opencode-btn opencode-btn-ai';
@@ -128,9 +127,9 @@ export class OpenCodeCellActions extends Widget {
return;
}
const prompt = new OpenCodeInlinePrompt(this._cell, {
- disabled: !this._context || this._status === 'loading',
+ disabled: !this._notebookPath || this._status === 'loading',
providers: _providers,
- notebookPath: this._context?.notebookPath,
+ notebookPath: this._notebookPath ?? undefined,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
@@ -196,14 +195,14 @@ export class OpenCodeCellActions extends Widget {
}
},
onCreateSession: async () => {
- if (!_serverSettings || !this._context?.notebookPath) {
+ if (!_serverSettings || !this._notebookPath) {
throw new Error('notebook not ready');
}
// Tear down any in-flight SSE first — the old session is
// being replaced.
this._closeSse();
const r = await callOpenCodeCreateNotebookSession(
- this._context.notebookPath,
+ this._notebookPath,
undefined,
_serverSettings
);
@@ -215,7 +214,7 @@ export class OpenCodeCellActions extends Widget {
return r.sessionId;
},
onSwitchSession: async (sessionId: string) => {
- if (!_serverSettings || !this._context?.notebookPath) {
+ if (!_serverSettings || !this._notebookPath) {
return false;
}
// Closing the SSE first ensures the old session's events stop
@@ -227,7 +226,7 @@ export class OpenCodeCellActions extends Widget {
// user just picked from the global list, we need to bind
// first — calling set_active before bind returns 400.)
const r = await callOpenCodeBindExistingSession(
- this._context.notebookPath,
+ this._notebookPath,
sessionId,
_serverSettings
);
@@ -239,8 +238,8 @@ export class OpenCodeCellActions extends Widget {
return true;
},
onReloadHistory: async () => {
- if (!this._context?.notebookPath) return;
- await this._refreshHistory(this._context.notebookPath);
+ if (!this._notebookPath) return;
+ await this._refreshHistory(this._notebookPath);
}
});
Widget.attach(prompt, this._cell.node);
@@ -248,7 +247,7 @@ export class OpenCodeCellActions extends Widget {
// Fetch the current session's static history and render it before
// streaming begins. Empty list is a normal no-op.
- const notebookPath = this._context?.notebookPath;
+ const notebookPath = this._notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
}
@@ -286,7 +285,7 @@ export class OpenCodeCellActions extends Widget {
providerId?: string,
modelId?: string
): Promise {
- if (!this._context) {
+ if (!this._notebookPath) {
return;
}
if (!_serverSettings) {
@@ -296,7 +295,7 @@ export class OpenCodeCellActions extends Widget {
const request = {
prompt: text,
- context: this._context,
+ context: { notebookPath: this._notebookPath },
providerId,
modelId
};
@@ -357,26 +356,23 @@ export class OpenCodeCellActions extends Widget {
}
/**
- * Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
- * then build the CellContext.
+ * Walk the parent chain to find the cell's parent NotebookPanel and
+ * return its content path. Returns null if the cell is detached
+ * (no notebook parent).
*/
-function extractCellContextFromCell(cell: CodeCell): CellContext | null {
+function extractNotebookPathFromCell(cell: CodeCell): string | null {
let node: Widget | null = cell.parent;
- let notebookPanel: NotebookPanel | null = null;
while (node) {
const candidate = node as any;
if (
candidate.context &&
+ typeof candidate.context.path === 'string' &&
candidate.content &&
Array.isArray(candidate.content.widgets)
) {
- notebookPanel = candidate;
- break;
+ return candidate.context.path;
}
node = node.parent;
}
- if (!notebookPanel) {
- return null;
- }
- return extractCellContext(cell, notebookPanel);
+ return null;
}
diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts
index 250a503..2c7f5b2 100644
--- a/src/components/opencode_inline_prompt.ts
+++ b/src/components/opencode_inline_prompt.ts
@@ -135,6 +135,7 @@ export class OpenCodeInlinePrompt extends Widget {
private _textarea: HTMLTextAreaElement;
private _sendBtn: HTMLButtonElement;
private _cancelBtn: HTMLButtonElement;
+ private _insertCellBtn: HTMLButtonElement;
private _providerSelect: HTMLSelectElement | null = null;
private _modelSelect: HTMLSelectElement | null = null;
private _providerModels: FlatProvider[];
@@ -238,8 +239,22 @@ export class OpenCodeInlinePrompt extends Widget {
options.onCancel();
});
+ // "📋 插入单元格内容" button: appends the current cell's source
+ // (wrapped in a markdown code fence) to the textarea. Lets the
+ // user explicitly opt-in to including the cell content in the
+ // LLM prompt — the cell is no longer auto-injected.
+ this._insertCellBtn = document.createElement('button');
+ this._insertCellBtn.type = 'button';
+ this._insertCellBtn.className = 'opencode-btn-insert-cell';
+ this._insertCellBtn.textContent = '📋 插入单元格内容';
+ this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
+ this._insertCellBtn.addEventListener('click', () => {
+ this._insertCellSource();
+ });
+
const actions = document.createElement('div');
actions.className = 'opencode-inline-actions';
+ actions.appendChild(this._insertCellBtn);
actions.appendChild(this._sendBtn);
actions.appendChild(this._cancelBtn);
@@ -598,6 +613,42 @@ export class OpenCodeInlinePrompt extends Widget {
this._textarea.value = '';
}
+ /**
+ * "📋 插入单元格内容" button: append the current cell's source as a
+ * markdown code block to the textarea. The LLM only sees what the
+ * user explicitly chose to include — the cell source is NEVER
+ * auto-injected (v0.2.x change).
+ */
+ private _insertCellSource(): void {
+ if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
+ Notification.info('当前 cell 没有内容');
+ return;
+ }
+ const source = this._cell.model.sharedModel.getSource() as string;
+ if (!source || !source.trim()) {
+ Notification.info('当前 cell 为空');
+ return;
+ }
+ // Use the cell's language as the fence info string when it
+ // looks like a real language identifier; otherwise use a plain
+ // fence so markdown stays portable.
+ const langRaw =
+ (this._cell.model as any).sharedModel && (this._cell.model as any).type
+ ? ((this._cell.model as any).type as string)
+ : 'python';
+ const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
+ const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
+ const before = this._textarea.value;
+ const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
+ const insertion = (needsLeadingNewline ? '\n' : '') + block;
+ this._textarea.value = before + insertion;
+ // Move cursor to end so the user can continue typing.
+ this._textarea.focus();
+ const end = this._textarea.value.length;
+ this._textarea.setSelectionRange(end, end);
+ this._scrollToBottom();
+ }
+
/**
* Bind the prompt to a specific OpenCode session id. Events arriving via
* `applyEvent` for a different session are dropped. Pass null to clear.
@@ -670,16 +721,25 @@ export class OpenCodeInlinePrompt extends Widget {
event.payload.syncEvent &&
event.payload.syncEvent.aggregateID);
- // Drop events for other sessions (defensive; matches demo.html
- // handleGlobalEvent). Only drop when the event has an explicit
- // sessionID — events without one (e.g. system events) are still
- // processed and may be dropped by _isSystemEvent below.
- if (sessionID && this._sessionId && sessionID !== this._sessionId) {
+ // System / workspace / pty / etc. events are not displayed.
+ if (this._isSystemEvent(type)) {
return;
}
- // System / workspace / pty / etc. events are not displayed.
- if (this._isSystemEvent(type)) {
+ // Cross-session filtering: only strict for permission/question
+ // events (the user must NOT be able to reply to a permission
+ // prompt that belongs to a different session). Content events
+ // (text delta, reasoning, tool, idle) pass through regardless of
+ // sessionID match — the user is looking at the active session's
+ // panel, and we don't want to drop their reply because of a
+ // sessionID extraction edge case. The server already forwards
+ // ALL events; the SSE handler routes by URL ?session= param.
+ if (
+ (type === 'permission.asked' || type === 'question.asked') &&
+ sessionID &&
+ this._sessionId &&
+ sessionID !== this._sessionId
+ ) {
return;
}
diff --git a/src/context/cell_context.ts b/src/context/cell_context.ts
deleted file mode 100644
index 51aa719..0000000
--- a/src/context/cell_context.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Extract a CellContext snapshot from a CodeCell + its parent NotebookPanel.
- *
- * Pure functions, no DOM, no signals — testable with plain object mocks.
- */
-import type { CodeCell, ICodeCellModel } from '@jupyterlab/cells';
-import type { NotebookPanel } from '@jupyterlab/notebook';
-
-import type { CellContext, ErrorOutput } from '../types';
-
-/**
- * Find the first error output in a code cell's outputs.
- * Returns null if the cell has no error output or is not a code cell.
- */
-export function collectErrorOutputs(model: ICodeCellModel): ErrorOutput | null {
- const outputs = model.outputs;
- for (let i = 0; i < outputs.length; i++) {
- const out = outputs.get(i);
- if (out && out.type === 'error') {
- const e = out as {
- ename?: unknown;
- evalue?: unknown;
- traceback?: unknown;
- };
- return {
- ename: String(e.ename ?? ''),
- evalue: String(e.evalue ?? ''),
- traceback: Array.isArray(e.traceback) ? (e.traceback as string[]) : [],
- };
- }
- }
- return null;
-}
-
-/**
- * Build a CellContext from a CodeCell and its parent NotebookPanel.
- * Returns null if essential data is missing.
- *
- * @param cell The code cell to inspect.
- * @param panel The notebook panel that contains the cell.
- */
-export function extractCellContext(
- cell: CodeCell,
- panel: NotebookPanel
-): CellContext | null {
- if (!cell || !panel) {
- return null;
- }
-
- const cells = panel.content.widgets;
- const cellIndex = cells ? cells.indexOf(cell) : -1;
- if (cellIndex < 0) {
- return null;
- }
-
- const totalCells = cells ? cells.length : 0;
- const previousCell = cellIndex > 0 ? cells[cellIndex - 1] : null;
- const previousCode = previousCell
- ? previousCell.model.sharedModel.getSource()
- : null;
-
- // Language: use 'python' as default for code cells.
- // Future improvement: read from kernel info_reply.
- const language = 'python';
-
- let error: ErrorOutput | null = null;
- if (cell.model.type === 'code') {
- error = collectErrorOutputs(cell.model as ICodeCellModel);
- }
-
- return {
- notebookPath: panel.context.path,
- cellId: cell.model.id,
- language,
- cellIndex,
- totalCells,
- source: cell.model.sharedModel.getSource(),
- previousCode,
- error,
- };
-}
diff --git a/src/types.ts b/src/types.ts
index c8ec533..faf604a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -10,21 +10,19 @@
* a JSON object with a `type` string; consumers route on `type`.
*/
-export interface ErrorOutput {
- ename: string;
- evalue: string;
- traceback: string[];
-}
-
+/**
+ * Minimal context carried with each edit request. Only the
+ * notebookPath is needed (so the server can pick the right OpenCode
+ * session). The cell source, previous-cell, and traceback are NO
+ * LONGER auto-injected into the LLM prompt — the user inserts them
+ * manually via the "📋 插入单元格内容" button (or pastes anything
+ * else they want) so the LLM only sees what they explicitly chose
+ * to send. v0.1.x used to pack a lot more here; that auto-wrap
+ * made the LLM context hard to reason about and made the user's
+ * intent ambiguous.
+ */
export interface CellContext {
notebookPath: string;
- cellId: string;
- language: string;
- cellIndex: number;
- totalCells: number;
- source: string;
- previousCode: string | null;
- error: ErrorOutput | null;
}
export interface OpenCodeRequest {
diff --git a/style/base.css b/style/base.css
index 1ced6b4..dd9adc7 100644
--- a/style/base.css
+++ b/style/base.css
@@ -191,6 +191,7 @@
display: flex;
justify-content: flex-end;
gap: 4px;
+ align-items: center;
}
.opencode-inline-prompt .opencode-inline-actions button {
@@ -211,6 +212,19 @@
cursor: default;
}
+/* The "📋 插入单元格内容" button: left-aligned, visually distinct from
+ send/cancel so the user can tell at a glance it's a "modifier"
+ rather than a "submit/cancel" action. */
+.opencode-inline-prompt .opencode-btn-insert-cell {
+ margin-right: auto; /* push to the left; send/cancel stay on the right */
+ background: #dbeafe;
+ color: #1e40af;
+ border-color: #93c5fd;
+}
+.opencode-inline-prompt .opencode-btn-insert-cell:hover:enabled {
+ background: #bfdbfe;
+}
+
/* ------------------------------------------------------------------
Streaming event blocks (v4+ — see opencode_inline_prompt.applyEvent)
------------------------------------------------------------------ */