Files
notebook-ai-extension/docs/superpowers/plans/2026-07-23-cell-toolbar-actions.md
T

21 KiB

AI 按钮迁入原生 Cell Toolbar — 实现计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal:✨ 优化 / 🐛 排错 / 🪄 编辑 三个按钮从 cell 底部 footer 迁移到 JupyterLab 原生 Cell toolbar(active cell 右上角、上移/下移按钮旁),并拆除整个 footer 链路。

Architecture: 新建 OpenCodeCellActions widget(构造函数直接接收 CodeCell),通过 IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', factory) 注册进原生 settings 驱动的 Cell toolbar;schema/plugin.jsonjupyter.lab.toolbars 贡献该项。已核实机制:createToolbarFactory 把 cell 透传给 item factory(apputils 4.5.10 lib/toolbar/factory.js:218);addFactory 签名见 lib/toolbar/registry.d.ts:36

Tech Stack: TypeScript / JupyterLab 4.5.10(npm deps)/ JupyterLab 4.6.2(runtime)/ jest / stylelint / prettier

Spec: docs/superpowers/specs/2026-07-23-cell-toolbar-actions-design.md(已提交)

Global Constraints

  • toolbar item 名固定为 opencode-cell-actions,index.ts 注册名与 schema 贡献名必须逐字一致。
  • CodeCell 显示按钮;非 CodeCell 返回空 Widget
  • 交互不变:🪄 编辑window.prompt;结果/错误统一走 Notification;widget 内不渲染 error span。
  • 无新 npm 依赖;不改 REST 契约;不改 Python 端。
  • 代码风格:prettier(single quotes、无 trailing commas、无 arrow parens);CSS 类名 kebab-case(stylelint)。
  • setOpenCodeRuntime({ settings, serverSettings }) 签名不变,仅从旧文件搬到新文件。

Task 1: OpenCodeCellActions 组件(TDD)

Files:

  • Test: src/__tests__/opencode_cell_actions.spec.ts(新建)
  • Create: src/components/opencode_cell_actions.ts(新建)

Interfaces:

  • Consumes: callOpenCodeEdit(request, serverSettings)(来自 ../api/opencode_client,不变);extractCellContext(cell, notebookPanel)(来自 ../context/cell_context,不变);../types 全部类型(不变)。

  • Produces: class OpenCodeCellActions extends Widget,构造函数 constructor(cell: CodeCell);setOpenCodeRuntime(args: { settings: OpenCodeSettings; serverSettings: ServerConnection.ISettings }): void;CSS 类 opencode-cell-actions / opencode-btn opencode-btn-{optimize|fix|edit}。Task 2 的 index.ts 依赖这两个导出。

  • Step 1: 写失败测试 — 新建 src/__tests__/opencode_cell_actions.spec.ts

/**
 * Unit tests for OpenCodeCellActions DOM rendering and button disabled states.
 *
 * Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the
 * test boundary so the real ESM packages are not loaded (Jest + pnpm can't
 * transform the hoisted @jupyterlab/* packages out of the box).
 */
jest.mock('@jupyterlab/cells', () => ({
  CodeCell: class CodeCell {}
}));

jest.mock('@jupyterlab/apputils', () => ({
  Notification: {
    info: jest.fn(),
    error: jest.fn(),
    success: jest.fn(),
    warning: jest.fn()
  }
}));

jest.mock('@lumino/widgets', () => {
  class Widget {
    public node: HTMLElement = document.createElement('div');
    public id = '';
    public parent: Widget | null = null;
    private _isDisposed = false;
    public get isDisposed(): boolean {
      return this._isDisposed;
    }
    public addClass(cls: string): void {
      this.node.classList.add(cls);
    }
    public dispose(): void {
      this._isDisposed = true;
    }
  }
  return { Widget };
});

import { OpenCodeCellActions } from '../components/opencode_cell_actions';
import { CodeCell } from '@jupyterlab/cells';

function makeFakeOutputs(items: any[]): any {
  return {
    get length() {
      return items.length;
    },
    get(i: number) {
      return items[i];
    }
  };
}

function makeFakeCell(
  source: string,
  errorOutputs: any[] = [],
  notebookPath = 'foo.ipynb',
  cellIndex = 0,
  totalCells = 1
): CodeCell {
  const model: any = {
    id: 'cell-test',
    type: 'code',
    sharedModel: {
      getSource: () => source
    },
    outputs: makeFakeOutputs(errorOutputs),
    contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
    stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
  };
  const cell = new (CodeCell as unknown as new () => CodeCell)();
  (cell as any).model = model;

  // Parent chain: cell -> notebook -> panel (mirrors real JupyterLab, where
  // the cell's grandparent Notebook matches the duck-typed panel lookup).
  const notebook: any = { widgets: [] as CodeCell[] };
  const panel: any = {
    context: { path: notebookPath },
    content: notebook
  };
  for (let i = 0; i < cellIndex; i++) {
    notebook.widgets.push({
      model: { sharedModel: { getSource: () => '' } }
    } as any);
  }
  notebook.widgets.push(cell);
  for (let i = cellIndex + 1; i < totalCells; i++) {
    notebook.widgets.push({
      model: { sharedModel: { getSource: () => '' } }
    } as any);
  }

  Object.defineProperty(cell, 'parent', {
    value: notebook,
    configurable: true
  });
  Object.defineProperty(notebook, 'parent', {
    value: panel,
    configurable: true
  });

  return cell as CodeCell;
}

describe('OpenCodeCellActions', () => {
  it('renders 3 buttons with the expected labels', () => {
    const cell = makeFakeCell('x = 1');
    const actions = new OpenCodeCellActions(cell);
    const btns = actions.node.querySelectorAll('button');
    expect(btns.length).toBe(3);
    expect(btns[0].textContent).toBe('✨ 优化');
    expect(btns[1].textContent).toBe('🐛 排错');
    expect(btns[2].textContent).toBe('🪄 编辑');
  });

  it('disables all buttons when the notebook panel cannot be resolved', () => {
    const cell = new (CodeCell as unknown as new () => CodeCell)();
    (cell as any).model = {
      id: 'orphan',
      type: 'code',
      sharedModel: { getSource: () => 'x = 1' },
      outputs: makeFakeOutputs([]),
      contentChanged: { connect: jest.fn(), disconnect: jest.fn() },
      stateChanged: { connect: jest.fn(), disconnect: jest.fn() }
    };
    // No parent chain: extractCellContextFromCell must return null.
    const actions = new OpenCodeCellActions(cell);
    const btns = actions.node.querySelectorAll('button');
    btns.forEach((b: HTMLButtonElement) => {
      expect(b.disabled).toBe(true);
    });
  });

  it('enables optimize and edit; fix stays disabled without an error', () => {
    const cell = makeFakeCell('x = 1', [], 'foo.ipynb', 0, 1);
    const actions = new OpenCodeCellActions(cell);
    const btns = actions.node.querySelectorAll('button');
    expect((btns[0] as HTMLButtonElement).disabled).toBe(false);
    expect((btns[1] as HTMLButtonElement).disabled).toBe(true);
    expect((btns[2] as HTMLButtonElement).disabled).toBe(false);
  });

  it('enables the fix button when the cell has an error output', () => {
    const cell = makeFakeCell(
      '1/0',
      [
        {
          type: 'error',
          ename: 'ZeroDivisionError',
          evalue: 'div by zero',
          traceback: []
        }
      ],
      'foo.ipynb',
      0,
      1
    );
    const actions = new OpenCodeCellActions(cell);
    const btns = actions.node.querySelectorAll('button');
    expect((btns[1] as HTMLButtonElement).disabled).toBe(false);
  });

  it('disconnects model signals on dispose', () => {
    const cell = makeFakeCell('x = 1');
    const actions = new OpenCodeCellActions(cell);
    actions.dispose();
    const model = (cell as any).model;
    expect(model.contentChanged.disconnect).toHaveBeenCalled();
    expect(model.stateChanged.disconnect).toHaveBeenCalled();
  });
});
  • Step 2: 跑测试确认失败

Run: jlpm test opencode_cell_actions Expected: FAIL — Cannot find module '../components/opencode_cell_actions'

  • Step 3: 实现组件 — 新建 src/components/opencode_cell_actions.ts
/**
 * Per-cell actions widget rendered inside JupyterLab's native Cell toolbar
 * (top-right of the active cell, next to the move up/down buttons).
 *
 * Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...);
 * the toolbar factory passes the owning Cell, so the cell arrives via the
 * constructor instead of being resolved from `this.parent` on attach.
 */
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';

import { callOpenCodeEdit } from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
  CellContext,
  OpenCodeMode,
  OpenCodeRequest,
  OpenCodeResponse,
  OpenCodeSettings
} from '../types';

// Module-level runtime injection (set by index.ts after settings load).
let _settings: OpenCodeSettings | null = null;
let _serverSettings: ServerConnection.ISettings | null = null;

export function setOpenCodeRuntime(args: {
  settings: OpenCodeSettings;
  serverSettings: ServerConnection.ISettings;
}): void {
  _settings = args.settings;
  _serverSettings = args.serverSettings;
}

type Status = 'idle' | 'loading';

export class OpenCodeCellActions extends Widget {
  private _cell: CodeCell;
  private _context: CellContext | null = null;
  private _status: Status = 'idle';

  constructor(cell: CodeCell) {
    super();
    this._cell = cell;
    this.addClass('opencode-cell-actions');
    this._cell.model.contentChanged.connect(this._onModelChange, this);
    this._cell.model.stateChanged.connect(this._onModelChange, this);
    this._onModelChange();
  }

  dispose(): void {
    if (this.isDisposed) {
      return;
    }
    this._cell.model.contentChanged.disconnect(this._onModelChange, this);
    this._cell.model.stateChanged.disconnect(this._onModelChange, this);
    super.dispose();
  }

  private _onModelChange(): void {
    this._context = extractCellContextFromCell(this._cell);
    this._render();
  }

  private _render(): void {
    const node = this.node;
    node.textContent = '';

    const hasError = this._context?.error != null;
    const loading = this._status === 'loading';
    const baseDisabled = !this._context || loading;

    const mkBtn = (
      label: string,
      mode: OpenCodeMode,
      disabled: boolean
    ): HTMLButtonElement => {
      const b = document.createElement('button');
      b.className = `opencode-btn opencode-btn-${mode}`;
      b.textContent = label;
      b.disabled = disabled;
      b.title = disabled
        ? loading
          ? 'OpenCode: 请求中…'
          : 'OpenCode: 等待 cell 上下文…'
        : label;
      b.addEventListener('click', () => {
        void this._onClick(mode);
      });
      return b;
    };

    node.appendChild(mkBtn('✨ 优化', 'optimize', baseDisabled));
    node.appendChild(mkBtn('🐛 排错', 'fix', baseDisabled || !hasError));
    node.appendChild(mkBtn('🪄 编辑', 'edit', baseDisabled));
  }

  private async _onClick(mode: OpenCodeMode): Promise<void> {
    if (!this._context) {
      return;
    }
    if (!_settings || !_serverSettings) {
      Notification.error('OpenCode 运行时未初始化,请检查 settings');
      return;
    }

    let prompt = '';
    if (mode === 'edit') {
      const input = window.prompt('请输入编辑指令:');
      if (input === null) {
        return;
      }
      prompt = input;
    }

    const request: OpenCodeRequest = {
      mode,
      prompt,
      context: this._context,
      providerId: _settings.opencodeProvider || undefined,
      modelId: _settings.opencodeModel || undefined
    };

    this._status = 'loading';
    this._render();

    try {
      const resp = await callOpenCodeEdit(request, _serverSettings);
      this._handleResponse(resp);
    } catch (e) {
      this._status = 'idle';
      this._render();
      Notification.error(`OpenCode ${mode} 失败: ${(e as Error).message}`);
    }
  }

  private _handleResponse(resp: OpenCodeResponse): void {
    this._status = 'idle';
    this._render();
    if (resp.ok) {
      const len = resp.finalSource.length;
      Notification.info(
        `OpenCode ${resp.mode} 完成 (${len} chars). ` +
          `Diff 面板将在 Slice 4 中提供。Session: ${resp.sessionId.slice(0, 8)}…`
      );
    } else {
      Notification.error(`OpenCode 错误: ${resp.error}`);
    }
  }
}

/**
 * Resolve a CodeCell's parent NotebookPanel by walking the parent chain,
 * then build the CellContext. (Unchanged from the old footer helper; the
 * `Widget.findParent` helper was removed in @lumino/widgets 2.x.)
 */
function extractCellContextFromCell(cell: CodeCell): CellContext | null {
  let node: Widget | null = cell.parent;
  let notebookPanel: NotebookPanel | null = null;
  while (node) {
    // Use duck-typing: a notebook panel has `context.path` and `content.widgets`.
    const candidate = node as any;
    if (
      candidate.context &&
      candidate.content &&
      Array.isArray(candidate.content.widgets)
    ) {
      notebookPanel = candidate;
      break;
    }
    node = node.parent;
  }
  if (!notebookPanel) {
    return null;
  }
  return extractCellContext(cell, notebookPanel);
}
  • Step 4: 跑测试确认通过

Run: jlpm test opencode_cell_actions Expected: PASS — 5 tests passed

  • Step 5: Commit
git add src/components/opencode_cell_actions.ts src/__tests__/opencode_cell_actions.spec.ts
git commit -m "feat: add OpenCodeCellActions widget for the native Cell toolbar"

Files:

  • Modify: src/index.ts(整体重写,下方给完整内容)
  • Delete: src/components/opencode_cell_footer.tssrc/components/opencode_cell_factory.tssrc/components/opencode_installer.tssrc/__tests__/opencode_cell_footer.spec.ts

Interfaces:

  • Consumes: Task 1 的 OpenCodeCellActions / setOpenCodeRuntime;IToolbarWidgetRegistry.addFactory<T>(widgetFactory: string, toolbarItemName: string, factory: (main: T) => Widget)(apputils 4.5.10)。

  • Produces: 注册名 opencode-cell-actions(Task 3 的 schema 必须逐字引用);插件不再依赖 INotebookTracker

  • Step 1: 重写 src/index.ts — 完整内容:

import {
  JupyterFrontEnd,
  JupyterFrontEndPlugin
} from '@jupyterlab/application';

import { IToolbarWidgetRegistry } from '@jupyterlab/apputils';
import { Cell, CodeCell } from '@jupyterlab/cells';
import { ISettingRegistry } from '@jupyterlab/settingregistry';
import { Widget } from '@lumino/widgets';

import {
  OpenCodeCellActions,
  setOpenCodeRuntime
} from './components/opencode_cell_actions';
import { requestAPI } from './request';
import { readOpenCodeSettings } from './types';
import { callOpenCodeProviders } from './api/opencode_client';

/**
 * Initialization data for the opencode_bridge extension.
 */
const plugin: JupyterFrontEndPlugin<void> = {
  id: 'opencode_bridge:plugin',
  description: 'A JupyterLab extension bridging the UI to OpenCode Serve.',
  autoStart: true,
  optional: [ISettingRegistry, IToolbarWidgetRegistry],
  activate: (
    app: JupyterFrontEnd,
    settingRegistry: ISettingRegistry | null,
    toolbarRegistry: IToolbarWidgetRegistry | null
  ) => {
    console.log('JupyterLab extension opencode_bridge is activated!');

    // Register the per-cell AI actions into the native Cell toolbar
    // (top-right of the active cell, next to move up/down). Non-code
    // cells get an empty widget so they show no AI buttons.
    if (toolbarRegistry) {
      toolbarRegistry.addFactory<Cell>(
        'Cell',
        'opencode-cell-actions',
        (cell: Cell) => {
          if (cell instanceof CodeCell) {
            return new OpenCodeCellActions(cell);
          }
          return new Widget();
        }
      );
    }

    // Load settings + push into the actions module.
    if (settingRegistry) {
      void settingRegistry
        .load(plugin.id)
        .then(settings => {
          const bridge = readOpenCodeSettings(settings.composite);
          setOpenCodeRuntime({
            settings: bridge,
            serverSettings: app.serviceManager.serverSettings
          });
          console.log('opencode_bridge settings loaded:', bridge);

          void callOpenCodeProviders(app.serviceManager.serverSettings)
            .then(data => {
              const lines: string[] = [
                '[opencode_bridge] Available OpenCode providers:'
              ];
              for (const p of data.providers) {
                const models = p.models.map(m => m.id).join(', ');
                lines.push(`  - ${p.id}: ${models || '(no models)'}`);
              }
              // eslint-disable-next-line no-console
              console.log(lines.join('\n'));
            })
            .catch(reason => {
              // eslint-disable-next-line no-console
              console.warn(
                '[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):',
                reason
              );
            });
        })
        .catch(reason => {
          console.error('Failed to load settings for opencode_bridge.', reason);
        });
    }

    requestAPI<unknown>('hello', app.serviceManager.serverSettings)
      .then(data => {
        console.log('hello endpoint:', data);
      })
      .catch(reason => {
        console.error(
          `The opencode_bridge server extension appears to be missing.\n${reason}`
        );
      });
  }
};

export default plugin;
  • Step 2: 删除 footer 链路文件
git rm src/components/opencode_cell_footer.ts src/components/opencode_cell_factory.ts src/components/opencode_installer.ts src/__tests__/opencode_cell_footer.spec.ts
  • Step 3: 确认无残留引用

Run: grep -rn "opencode_cell_footer\|opencode_cell_factory\|opencode_installer\|installOpenCode" src/ schema/ style/ Expected: 无任何输出

  • Step 4: 编译 + 测试

Run: jlpm build && jlpm test Expected: tsc + jupyter-builder 成功;jest 全部通过(8 个测试:cell_context 2 + opencode_bridge 1 + opencode_cell_actions 5 + opencode_client 若干,数量以实际输出为准,关键是无 footer 相关失败)

  • Step 5: Commit
git add src/index.ts
git commit -m "refactor: register AI actions in native Cell toolbar; remove cell footer chain"

Task 3: schema 贡献 + 样式

Files:

  • Modify: schema/plugin.json(顶层加 jupyter.lab.toolbars)
  • Modify: style/base.css

Interfaces:

  • Consumes: Task 2 注册的 item 名 opencode-cell-actions;Task 1 的 CSS 类 opencode-cell-actions / opencode-btn

  • Produces: 无新接口。

  • Step 1: 修改 schema/plugin.json — 在第 2 行 "jupyter.lab.shortcuts": [], 之后插入:

  "jupyter.lab.toolbars": {
    "Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
  },

修改后文件头部应为:

{
  "jupyter.lab.shortcuts": [],
  "jupyter.lab.toolbars": {
    "Cell": [{ "name": "opencode-cell-actions", "rank": 100 }]
  },
  "title": "opencode_bridge",
  ...

(原生默认按钮 rank 均为 50,我们排 100 → 出现在 delete-cell 右侧。)

  • Step 2: 写 style/base.css — 在现有注释块之后追加:
/* AI action buttons inside the native Cell toolbar (active cell, top-right). */
.opencode-cell-actions {
  display: flex;
  align-items: center;
}

.opencode-cell-actions .opencode-btn {
  border: none;
  background: transparent;
  padding: 0 4px;
  font-size: var(--jp-ui-font-size1);
  line-height: 20px;
  cursor: pointer;
  white-space: nowrap;
}

.opencode-cell-actions .opencode-btn:hover:enabled {
  background: var(--jp-layout-color2);
  border-radius: 2px;
}

.opencode-cell-actions .opencode-btn:disabled {
  opacity: 0.4;
  cursor: default;
}
  • Step 3: lint + build

Run: jlpm lint:check && jlpm build Expected: stylelint / prettier / eslint 全过;build 成功

  • Step 4: Commit
git add schema/plugin.json style/base.css
git commit -m "feat: contribute opencode-cell-actions to Cell toolbar settings + styles"

Task 4: design.md 更新 + 全量验证

Files:

  • Modify: design.md(第 2 节"交互与UI流程设计"第 1 条)

Interfaces:

  • Consumes: 无。

  • Produces: 无。

  • Step 1: 更新 design.md — 把第 2 节第 1 条(1. **悬浮 Toolbar(常驻)**:在每个 CodeCell 顶部渲染微型操作栏: 整段及其 3 个子条目)替换为:

1. **原生 Cell Toolbar 集成(2026-07-23 修正)**:不再自绘悬浮 toolbar。`✨ 优化` / `🐛 排错`(仅当 Cell 有 Error Output 时可用)/ `🪄 编辑` 三个按钮通过 `IToolbarWidgetRegistry.addFactory('Cell', 'opencode-cell-actions', ...)` 注入 JupyterLab 原生 cell toolbar,显示在 **active cell 右上角**(move up/down 按钮旁,`rank: 100` 排在 delete 右侧);仅 `CodeCell` 显示,随 active cell 切换移动。
  • Step 2: 全量验证

Run: jlpm test && jlpm lint:check && jlpm build Expected: 全部通过

  • Step 3: Commit
git add design.md
git commit -m "docs: update design.md interaction flow for native Cell toolbar"
  • Step 4: 手动验收(用户执行,非自动化)jupyter lab 打开任一 notebook:active cell 右上角出现三按钮(在 delete 右侧);无错误 cell 上 🐛 排错 禁用;切换 active cell 按钮随之移动;cell 底部不再有 footer 按钮条。