58 Commits
Author SHA1 Message Date
tao.chen e742bf4527 docs: handover — capture v0.1.2 state, feature/floating-ai-button unmerged, Codex pain 2026-07-29 14:26:20 +08:00
tao.chen d6507368e2 chore: bump version to 0.1.2
CI / CI (push) Failing after 18m48s
Floating panel UI work + hide+show accumulation fix.
v0.1.2
2026-07-29 14:15:58 +08:00
tao.chenandClaude f9d52f7b8e docs: add HANDOVER.md for next session context
The current session's context window is nearly full. This document
captures the project state, the key design decisions that future
maintainers should NOT re-derive, and the known minor issues that
were intentionally left for a follow-up session.

Contents:
- Project overview + current state (tag v0.1.1, branch main @ 310d507)
- Architecture diagram
- Critical code locations
- All 11 route endpoints in one table
- 5 key design decisions (SSE filter, no cell auto-inject, multi-session
  model, no partial-render markdown, real interactive permission/question
  UI) - written so the next session doesn't accidentally undo them
- How to run the tests
- Known minor issues / TODO list

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 18:10:20 +08:00
tao.chen 310d50759a fix: SSE filter no longer drops content events for a different session
Bug: after switching to a new session via the SessionSelector, the
next /edit would not get a reply. Root cause: applyEvent dropped
ANY event whose sessionID didn't match the prompt's _sessionId,
including text deltas / reasoning / tool / idle. OpenCode's sessionID
extraction from event payloads is not perfectly consistent across
event types, so the prompt could end up with a stale _sessionId
that doesn't match the events coming in, and EVERY event got
filtered out -> no reply.

Fix: the cross-session filter is now strict ONLY for
permission.asked / question.asked (so the user can't accidentally
reply to another session's prompt). Content events always pass
through. The server's /events handler is the single source of
truth for session filtering (it has the URL ?session= param); the
client's filter would only ever mask the user's interaction with
their own active session.

Also: drop the cell-context auto-injection. The OpenCodeRequest
now carries only {notebookPath}. The user explicitly attaches
whatever they want via the new '📋 插入单元格内容' button
(inserts the cell source as a markdown code block into the input).
This makes the LLM context match user intent and stops the
OpenCode prompt from being polluted with stale previousCode /
traceback snapshots.

Backend
-------
- _build_request_body: now takes only the prompt, returns
  parts=[{text: prompt}]. No more <previous_cell>/<traceback>/<cell>
  tag wrapping.

Frontend
-------
- types.ts: CellContext collapsed to {notebookPath}. ErrorOutput /
  cellId / source / previousCode / error / cellIndex / totalCells
  / language all removed.
- opencode_cell_actions.ts: extractCellContextFromCell() replaced
  with extractNotebookPathFromCell(). _context field renamed to
  _notebookPath. NotebookPanel / extractCellContext / context/
  cell_context imports all removed.
- src/context/cell_context.ts + src/__tests__/cell_context.spec.ts:
  deleted.

New feature: '📋 插入单元格内容' button
----------------------------------------
The button sits at the start of the actions row (visually
left-aligned via margin-right:auto) and appends the current cell's
source as a markdown code fence to the textarea. Caret is moved
to the end so the user can keep typing. The fence info string is
the cell's model type (falls back to a plain fence if the type
isn't a clean language identifier). No-op for empty cells.

Tests
-----
- 72 jest (was 78: -8 cell-context tests + 1 SSE filter test +
  3 insert-cell tests + rewrites). 73 pytest (unchanged). Build
  green.
2026-07-28 18:02:21 +08:00
tao.chenandClaude 4ff5d7021c refactor: dedup setSessionId calls in session-switch path; add test coverage
Code review of 893f049 flagged 6 findings. This commit addresses them all:

#1 (real issue, production code): setSessionId was being called
TWICE per session switch - once explicitly from the cell-action
callback, and again from the prompt's _handleSwitchSession (which
also assigned _sessionId, called _resetStreamPointers, and set
_sessionSelect.value directly). The explicit setSessionId in the
cell-action callbacks (onCreateSession, onSwitchSession) is now
removed; the prompt's handlers do all of that bookkeeping through
a single setSessionId() call, which already implements the
dropdown + reset-stream-pointers logic.

#2 / #4 (test coverage): the existing switching test only checked
that the right HTTP call was made. New assertions: the prompt's
session-messages mock IS called again (history actually reloaded)
and the rendered history reflects the new session's content
(verifies the user sees the right messages, not stale DOM).

#3 (test coverage): new test for the error path. bind_existing
returns ok:false -> the prompt rolls the dropdown back to the
previous active id, leaves _sessionId unchanged, and shows a
Notification.error. This locks in the rollback behavior so a
future refactor can't silently break it.

#5 (test cleanup): mockedCallOpenCodeSessionMessages (the spy used
to verify history reload) is now declared at the top of the spec
file alongside the other mocked* variables, with a meaningful
name (mockedSessionMessages) instead of an inline jest.mocked()
that was hard to find.

#6 (source-doc): added a TODO(frontend) comment on
callOpenCodeSetActiveSession explaining why it is currently
unused (kept for a future 'switch between already-bound sessions'
flow that would avoid the bind round-trip) and warning a future
maintainer not to delete the corresponding server route without
removing the client function (or vice versa).

Tests: 77 jest (was 76), 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 17:00:29 +08:00
tao.chenandClaude 893f049eb7 fix: call bind (not set_active) when switching to a fresh session
Bug: 400 from PUT /opencode-bridge/sessions/active when the user picked
a session from the global list that wasn't yet bound to this notebook.
The cell-action onSwitchSession callback was:
  1. PUT /sessions/active (server checks bound -> 400)
  2. PUT /sessions/notebook (bind, but never reached)
The order was wrong: set_active requires the session to be bound FIRST.

Fix: the PUT /sessions/notebook route does both bind AND set active
in a single call (server handler: bind_existing() then response
includes the new activeSessionId). So the callback now only needs to
call bind-existing. The /sessions/active endpoint is still useful for
future "switch between already-bound sessions" flows but the current
"pick from global list" path doesn't need it.

Tests: updated the switching test to assert bind-existing is called
and set-active is NOT. Also added not.toHaveBeenCalled() to lock in
the new behavior. 76 jest, 73 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:39:27 +08:00
tao.chenandClaude d2bd4f1e48 feat: multi-session management (browse all + bind N sessions per notebook)
User can now see EVERY session on the OpenCode Serve side (not just
the per-notebook mapping) and bind multiple sessions to a single
notebook. One of the bound sessions is the "active" one (the one
the next /edit uses); the others are historical. The user can
switch, create, or delete via the new session selector UI in the
inline prompt.

Backend
-------
- SessionManager rewritten for 1-notebook-N-sessions: keeps both an
   map and a  map. New methods: create_new,
  bind_existing, set_active, unbind, unbind_active, delete_session,
  list_sessions_for_notebook. Existing get_or_create / peek /
  invalidate / list_sessions / release semantics preserved
  (release now deletes ALL bound sessions, not just the active).
- New route GET /opencode-bridge/sessions/all — list ALL OpenCode
  sessions via /session.
- New route GET /opencode-bridge/sessions/notebook?notebook=... —
  bound sessions for one notebook, enriched with title/createdAt
  from the global list.
- POST .../sessions/notebook — create a new session, bind, set
  active.
- PUT  .../sessions/notebook — bind an existing sessionId, set
  active (no OpenCode round-trip).
- DELETE .../sessions/notebook — delete the session on OpenCode
  AND remove its binding.
- New route GET/PUT/DELETE /opencode-bridge/sessions/active —
  read / switch / unbind the active session for one notebook.
- OpenCodeClient.list_all_sessions() — proxy OpenCode /session.

Frontend
-------
- SessionSelector dropdown in the inline prompt: lists ALL OpenCode
  sessions with the active one selected, plus a "+ 新建会话"
  sentinel entry. "+ 新建" button + "🔄" refresh button.
  Switching calls onSwitchSession; selecting "+ 新建会话" or
  clicking the new button calls onCreateSession. After both,
  the prompt's history is reloaded for the new active session.
- New cell-action callbacks: onListSessions, onCreateSession,
  onSwitchSession, onReloadHistory. Each closes the SSE first
  so events for the OLD session don't bleed in during the switch.
- 8 new API client functions in api/opencode_client.ts.
- New types in types.ts: OpenCodeSessionMeta,
  OpenCodeNotebookSession, OpenCodeAllSessionsResponse,
  OpenCodeNotebookSessionsResponse, OpenCodeSessionOpResponse.

Tests
-----
- 73 backend pytest (was 64): +6 new SessionManager multi-session
  unit tests + 7 new route tests (all-sessions, notebook GET/POST/
  PUT/DELETE, active GET/PUT/DELETE).
- 76 frontend jest (was 72): +4 new prompt tests (session selector
  rendered when callbacks wired, initialize() populates the list,
  + 新建 creates+binds, dropdown change switches and reloads).
- package.json version bumped 0.1.0 -> 0.1.1 to match the v0.1.1 tag
  that was already published.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-28 16:25:42 +08:00
tao.chenandClaude fcd015ac0e fix: stop running marked.parse on every streaming text delta
CI / CI (push) Successful in 17m54s
The streaming path used to call _renderAssistantMarkdown on every
incoming text delta, which re-ran marked.parse on the WHOLE
accumulated markdown source and re-post-processed (added the
Copy/Insert/Replace toolbar to) every <pre> block.

This is a partial-render problem: a markdown source mid-stream (e.g.
an opening ```py fence with no closing fence yet, or a partial
heading) classifies DIFFERENTLY than the complete message. Each
new delta could re-classify the same text into a different DOM
shape, causing visible flicker - <pre>s appearing and disappearing,
the toolbar popping in and out, paragraphs re-grouping - while the
model is still typing. The bug surfaced because the user could see
the glitchy view during a stream but everything looked correct
after closing+reopening the panel (which goes through the static
setMessages -> marked.parse path on the full text).

Fix: keep the streaming path dumb. _appendToAssistantText now just
appends the raw delta to the assistant element's textContent. The
full markdown render happens exactly once at the end of the turn,
inside _resetStreamPointers (which the prompt invokes when
session.idle arrives, before calling onStreamEnd). Result:

- The user still sees real-time text growth (textContent += delta).
- No more intermediate-state flicker.
- The rendered view at turn end is byte-for-byte equivalent to what
  setMessages would produce from the stored history (they share
  _renderAssistantMarkdown).

The corresponding regression test is updated: it now asserts the
DURING-stream state is raw text (no <pre>, no toolbar) and the
POST-idle state is the rendered DOM with the toolbar attached.

Tests: 72 jest (unchanged count, the one streaming test was
rewritten), 52 pytest, build green.

Co-Authored-By: Claude <noreply@anthropic.com>
v0.1.1
2026-07-27 17:52:53 +08:00
tao.chen d867b2f6d6 Merge remote-tracking branch 'origin/main' 2026-07-27 17:40:49 +08:00
tao.chen b4060d3b2f Merge pull request 'Feat/async sse streaming' (#1) from feat/async-sse-streaming into main
Reviewed-on: #1
2026-07-27 17:38:53 +08:00
dd5667cea4 feat: persist user (provider, model) selection per notebook in localStorage
When the user picks a provider+model in the inline prompt, remember
that choice across panel close/reopen AND across JupyterLab restarts,
keyed by notebookPath. If the stored provider (or the model under it)
is no longer present in the current providers payload, fall back to
the default (first provider / default[pid] model) - never silently
apply a stale value that would result in an empty or disabled select.

- New module src/components/model_selection.ts with
  loadModelSelection / saveModelSelection / clearModelSelection.
  Best-effort: localStorage may be disabled (private mode), quota
  may be exhausted, value may be corrupt - all failure modes return
  null / no-op rather than throw.
- IOpenCodeInlinePromptOptions gains notebookPath?: string.
- Constructor resolves the initial selection: prefer stored+valid,
  else first provider / default[pid]. providerSelect.change and
  modelSelect.change listeners call _persistSelection() to keep
  storage in sync.
- _rebuildModelSelect now accepts a preferredModelId; if it exists
  in the current provider's models it's used, else the default.
- OpenCodeCellActions._showPrompt passes notebookPath =
  this._context?.notebookPath so the prompt can key the storage.

Tests
-----
- 10 unit tests in model_selection.spec.ts (load/save/clear +
  malformed JSON + per-notebook independence).
- 4 prompt behavior tests in opencode_cell_actions.spec.ts
  (valid stored applied, provider removed -> default, model removed
  -> default, change -> saved).
- 72 frontend jest (was 58), 52 backend pytest (unchanged),
  build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
b7089519fd feat: async + SSE message flow with interactive permission/question UI
Replace the synchronous /edit (wait for full markdown response) with an
async + SSE flow per demo.html so the user sees text stream in real-time
and can interact with permission/question events the agent raises.

Backend
-------
- EditHandler now calls OpenCode /session/:id/prompt_async and returns
  immediately with {ok, sessionId, notebookPath}; the LLM reply is no
  longer embedded in this response.
- New GlobalEventHandler proxies OpenCode /global/event as
  text/event-stream. Server forwards ALL events; the client filters.
  A too-eager server-side ?session= filter was silently dropping events
  the client would have accepted, so it was removed.
- New PermissionReplyHandler + QuestionReplyHandler forward user
  replies (once/always/reject and freeform answer) back to OpenCode
  Serve at /session/:sid/permissions/:permId and
  /session/:sid/question/:qId/reply.
- OpenCodeClient gains send_message_async(), stream_global_events()
  (async generator over the SSE feed via tornado streaming_callback),
  reply_permission() and reply_question(). Legacy send_message_sync
  removed.

Frontend
--------
- subscribeOpenCodeEvents() opens a fetch+reader SSE client (XSRF
  token injected from serverSettings); AbortController-backed close()
  is idempotent.
- OpenCodeInlinePrompt.applyEvent() routes events into the streaming
  UI: text delta -> assistant message (re-rendered as markdown on
  every delta so the user sees formatted <pre><code> blocks in
  real-time, not raw fence source); reasoning/tool/permission/question
  get collapsible details blocks. session.idle resets stream pointers
  and fires onStreamEnd.
- Permission/question blocks render real interactive UI: three
  buttons (once/always/reject) for permission, a text input + submit
  for question. Click handlers post through the new API routes and
  show success/failure status in place.
- Centralized idle detection (4 shapes: top-level + payload-nested
  x {session.idle, session.status/idle}) inside the prompt; cell
  action reacts via onStreamEnd callback rather than re-parsing
  event types.
- System / workspace / pty / lsp / mcp / installation events AND
  session-level control events (agent.switched, model.switched,
  file.edited) are not rendered in the frontend.

Tests
-----
- 52 backend pytest (was 43)
- 58 frontend jest (was 46)

design.md section 3 updated for the new async /edit response shape
and the new GET /events SSE endpoint.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 17:38:53 +08:00
tao.chenandtao.chen a43174e5bc update: optimize config.py 2026-07-27 17:38:53 +08:00
tao.chen 0bd04a825d update: optimize config.py 2026-07-27 14:33:04 +08:00
tao.chen 380e4a3150 更新 .github/workflows/build.yml 2026-07-24 11:02:27 +08:00
tao.chen b0abeaf329 更新 .github/scripts/create-release.sh 2026-07-24 10:59:47 +08:00
tao.chen a502f58d26 chore: update dependence
CI / CI (push) Successful in 30m13s
v0.1.0
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
tao.chenandClaude Fable 5 ce59502e97 feat: inline prompt history area — scrollable session messages
The inline prompt now shows the current session's full message
history (user + assistant), scrollable, replacing the single-output
display from the previous commit. The user can scroll back through
prior exchanges in the same notebook's OpenCode session.

Server (opencode_bridge/):
  - New route: GET /opencode-bridge/session-messages?notebook=<path>
    - Resolves notebook to session via SessionManager.peek (no create).
    - If no session: {"messages": []} (no OpenCode call).
    - Else: GET /session/{id}/message on OpenCode Serve, project the
      raw {info, parts}[] into a frontend-friendly {role, content}[].
  - OpenCodeClient.list_session_messages(sid).
  - SessionManager.peek(notebook_path) -> Optional[str] (read without
    creating, to avoid spawning a session just to report emptiness).
  - Wired the new route in setup_route_handlers.

Client (src/):
  - types.ts: OpenCodeMessage { role, content } + OpenCodeMessagesResponse.
  - api/opencode_client.ts: callOpenCodeSessionMessages(notebook, serverSettings).
  - components/opencode_inline_prompt.ts:
    - Replaces the single .opencode-inline-output area with a
      scrollable .opencode-inline-history (max-height 320px, overflow-y
      auto, auto-scrolls to bottom on update).
    - setMessages(messages): renders user messages as plain text,
      assistant messages via marked.parse. No more setOutput/hideOutput.
  - components/opencode_cell_actions.ts:
    - _showPrompt now fires a _refreshHistory(notebookPath) which
      fetches and calls prompt.setMessages.
    - _handleResponse on success also calls _refreshHistory (the new
      assistant message appears as the last item in the history).
    - Cell source is still NOT replaced.
  - api/opencode_client module is mocked in the cell_actions test to
    avoid jsdom network calls.

style/base.css:
  - .opencode-inline-output* rules replaced with .opencode-inline-history
    (max-height 320px, overflow-y auto, border, padding) and
    .opencode-msg / .opencode-msg-user / .opencode-msg-assistant.
  - pre/code/p/h1-3 content styling scoped under .opencode-inline-history.

Tests:
  - pytest 37/37: FakeOpenCodeClient.list_session_messages + 3 new
    session_messages route tests (no session, projects messages, 400).
  - jest 29/29: setMessages renders user/assistant + history area tests.
  - FakeSessionManager.peek (returns None when session_id is falsy).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:45:03 +08:00
tao.chenandClaude Fable 5 1d7f5da9d4 feat: inline markdown output box — render AI reply with marked, do not replace cell
Flow (v3-final corrected):
  1. Model call succeeds.
  2. Server passes the AI reply through unchanged as 'markdown' (the
     system prompt allows ```language fences + a brief explanation, so
     the response is real markdown that marked can render into code
     blocks, headings, etc.).
  3. Client OpenCodeCellActions._handleResponse calls prompt.setOutput(
     resp.markdown); the inline prompt widget renders it with
     marked.parse and shows it in a new output area (hidden until a
     response arrives). The cell source is NOT replaced.
  4. User can close the output or cancel the whole prompt.

Server:
  - New unified system prompt: '你是代码助手 ... 按指令修改代码,可附简
    短说明' (allows ```fences``` + explanation; no more 'no markdown
    fences' restriction).
  - EditHandler returns {ok, markdown, sessionId, notebookPath} (raw
    text, fences intact). _strip_code_fence kept as a helper for any
    future apply-to-cell path; no longer called.
  - finalSource field dropped (the cell-apply path is gone).

Client:
  - OpenCodeSuccess: markdown: string (finalSource removed).
  - OpenCodeInlinePrompt: new output area, setOutput(md) renders via
    marked.parse, hideOutput() closes it.
  - OpenCodeCellActions._handleResponse: setOutput(markdown) instead of
    sharedModel.setSource + auto-hide. The prompt stays open so the
    user can read the output.
  - Uses marked@17 (already in node_modules via JupyterLab; no new dep).
  - CSS: output area styling (border, max-height 320px scroll, code/pre
    styling, close button).

Tests: pytest 34/34, jest 29/29. marked is mocked in jest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:29:58 +08:00
tao.chenandClaude Fable 5 04f90ab5a0 feat: dual select (provider + model) with default[provider] model default
The inline prompt now renders two <select> boxes:
  - Provider: lists every provider from the cached /config/providers
    response. Default = first provider.
  - Model: lists that provider's model keys (Object.keys of the Record).
    Default = default[providerID] (from the same response) when that
    modelID is present in the provider's models; otherwise the first
    model. Changing the provider select rebuilds the model select with
    the new provider's models and its own default[provider].

On submit the two select values are read separately and passed as
providerId / modelId to the OpenCodeRequest (no more '|' delimiter).
The /opencode-bridge/edit body shape is unchanged (providerId, modelId).

Also fix the index.ts startup console log (p.models is a Record, use
Object.values), and drop the unused _cell field on the prompt widget
(now an unused param).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:13:14 +08:00
tao.chenandClaude Fable 5 e7579d3779 fix: Provider.models is a Record keyed by modelID, not an array
Per the OpenCode server docs (GET /config/providers), each Provider's
'models' is { [modelID: string]: Model } (a Record), so model IDs can be
referenced as strings (matching the 'default' map of providerID ->
modelID). Our code treated it as Model[], which:
  1. crashed with 'p.models is not iterable' on real responses, and
  2. after the previous Array.isArray guard, skipped EVERY provider
     (a Record is never an array), so the inline picker showed nothing.

Update types.ts (OpenCodeProvider.models -> Record, add OpenCodeModel,
add optional 'default' map to the response) and rewrite
flattenProviders to iterate Object.keys(p.models). Update test fixture
to the Record shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:01:16 +08:00
tao.chenandClaude Fable 5 f7099be060 fix: guard flattenProviders against non-array p.models
Some OpenCode Serve /config/providers responses include providers
without a 'models' field (or with a non-array value). The un-guarded
for-of on p.models then throws 'p.models is not iterable' and crashes
the inline prompt construction. Skip such providers instead.

Also re-assert the button label as 'AI' (matches the df2f3df label
simplification, which the 02cedac revert had bundled away).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:58:29 +08:00
tao.chen 02cedac138 Revert "fix: attach inline prompt to cell.inputArea (under the editor), not cell.node"
This reverts commit 8ed55c43c7.
2026-07-23 17:56:09 +08:00
tao.chenandClaude Fable 5 8ed55c43c7 fix: attach inline prompt to cell.inputArea (under the editor), not cell.node
Previously the inline prompt was Widget.attach'd to cell.node, which
in JupyterLab places it at the very bottom of the cell (after the
output area and In/Out prompt) — so the user couldn't see it, especially
on taller cells. Attaching to cell.inputArea.node puts the prompt
directly under the editor, which is the natural inline position.
Falls back to cell.node if inputArea is unexpectedly null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:54:07 +08:00
tao.chenandClaude Fable 5 df2f3dff95 style: simplify button labels (drop emoji, keep text)
- 🪄 AI 智能编辑 → AI
- 🚀 发送 → 发送
- ✕ 取消 → 取消

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:41:38 +08:00
tao.chenandClaude Fable 5 b6c0273ee0 test: cover auth-on-request and close() in OpenCodeClient
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:40:58 +08:00
tao.chenandClaude Fable 5 c86de1d31b fix: set Basic Auth on HTTPRequest, not as fetch kwargs; close client cleanly
tornado rejects fetch(request, **kwargs) when the first arg is already an
HTTPRequest and the kwargs overlap request construction (auth_username /
auth_password). Move auth onto the HTTPRequest. Add OpenCodeClient.close()
to release its AsyncHTTPClient; module-level atexit closes the tornado
singleton so 'jupyter lab' can exit cleanly after SIGINT instead of
hanging on 'received signal 2, stopping'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:40:57 +08:00
tao.chenandClaude Fable 5 ff932d9095 docs: v4 plan for OpenCodeClient auth + close() bugfixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:13:41 +08:00
tao.chen 5306ada0c4 style: polish inline prompt model picker layout 2026-07-23 16:11:25 +08:00
tao.chen 74953d0ab8 feat: fully dynamic model picker in inline prompt 2026-07-23 16:11:20 +08:00
tao.chenandClaude Fable 5 74de18646f docs: amend v3 plan to v3-final (no settings, fully dynamic model picker)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:59:59 +08:00
tao.chenandClaude Fable 5 1e42459560 docs: design.md v3-final (startup env + dynamic model picker, no settings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 15:56:07 +08:00
tao.chen b5bcb8f67c build: use node-modules linker so plain jlpm works
Removes the pnpm node-linker so 'jlpm test' / 'jlpm build' run directly
without the YARN_NODE_LINKER=node-modules env override. Re-run 'jlpm
install' to regenerate node_modules in the flat layout (the in-flight
yarn.lock migration in package.json already targets node-modules).
2026-07-23 15:53:30 +08:00
tao.chen f9b93906c6 refactor: move all server config to startup env vars; drop plugin settings 2026-07-23 13:21:31 +08:00
tao.chenandClaude Fable 5 ca2281af3b docs: amend v3 spec to v3-final — remove all plugin settings, fully dynamic model picker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:14:41 +08:00
tao.chenandClaude Fable 5 8b51eda2ff docs: v3 implementation plan for startup env + model selector
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:11:18 +08:00
tao.chenandClaude Fable 5 12c0f4c618 docs: v3 spec for startup env config + inline model selector
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 13:11:18 +08:00
tao.chen 24bff6d2eb style: single AI button + inline prompt panel CSS 2026-07-23 12:47:49 +08:00
tao.chen 85e16914b5 feat: single AI action button with inline prompt; replace cell source 2026-07-23 12:47:47 +08:00
tao.chen d03f0fa434 refactor: drop mode from frontend types; react to settings changes 2026-07-23 12:47:44 +08:00
tao.chenandClaude Fable 5 04d011afa1 docs: design.md v2 interaction + contract (single button, no mode)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:37:49 +08:00
tao.chen 42ea857313 refactor: drop mode from backend; unify system prompt 2026-07-23 12:31:28 +08:00
tao.chenandClaude Fable 5 b5adf331a6 docs: v2 implementation plan for cell toolbar actions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:29:23 +08:00
tao.chenandClaude Fable 5 50ba847659 docs: v2 spec for single AI button + inline prompt + drop mode
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 12:29:23 +08:00