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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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).