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) ------------------------------------------------------------------ */