Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
- Fixed F5 not refreshing the Markdown Preview from disk
- Added font zoom (Alt+Scroll) for Terminal and Markdown Preview; Ctrl+0 resets; right-click "Zoom" submenu
- Added Find bar (Ctrl+F / F3 / Shift+F3) to Markdown Preview
- Added automatic Markdown Preview tab when Claude writes a plan file (configurable in Settings → General)
- Added `show_markdown` MCP tool: displays markdown text in a NetBeans Markdown Preview tab
Expand Down
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ When finishing a release by adding `# MAJOR.MINOR` to CHANGELOG.md, the commit m

A NetBeans IDE plugin that embeds Claude Code CLI as a PTY-based terminal session connected to the IDE via three channels: PTY, MCP SSE, and an HTTP hook.

### Why JediTerm and not NetBeans' built-in terminal

The terminal is JetBrains **JediTerm** (`jediterm-core`/`jediterm-ui`), not NetBeans' built-in
terminal (`org.netbeans.lib.terminalemulator.Term` / the "Open in Terminal" action). The built-in
terminal was evaluated and rejected for the following reasons:

- **Broken TUI rendering (decisive).** `lib.terminalemulator` does not support the alternate
screen buffer (DECSET 1049) nor 256-color SGR codes (`ESC[38;5;Nm` / `ESC[48;5;Nm`). Claude Code
is a full-screen `xterm-256color` TUI that relies on both — it would render corrupted.
- **No screen-buffer introspection.** Only whole-row text (`Term.getRowText(int)`) is exposed; no
per-cell/attribute access. `ScreenContentDetector` polls the rendered screen to detect
interactive choice menus, which would be unreliable without cell/attribute data.
- **Context menu not extensible.** `Term`'s right-click menu is hardcoded in
`org.netbeans.modules.terminal.ioprovider.Terminal` with no public hook. JediTerm, by contrast,
exposes a `createPopupMenu` / `TerminalActionProvider` extension point (see `ZoomableJediTermWidget`).
- **No per-instance font/zoom API.** Term font size is a global setting only — incompatible with
the per-surface zoom feature.
- **Friend-only API.** `lib.terminalemulator` and `terminal` declare no public packages
(friend-package restricted), so they are not a supported third-party API and may break between
NetBeans releases.

Only PTY hosting is equivalent (`StreamTerm.connect(streams)` vs. JediTerm's `TtyConnector`).
Replacing JediTerm would mean rewriting `process/`, `controller/`, and screen detection for a
net loss of capability — do not pursue it.

### Process startup

`ClaudeProcess.start(workingDir, profile, extraCliArgs, mode, resumeSessionId)`:
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbclaudecodegui</groupId>
<artifactId>netbeans-claude-code-gui</artifactId>
<version>0.23.12-SNAPSHOT</version>
<version>0.23.17-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Plugin Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
72 changes: 69 additions & 3 deletions src/main/java/io/github/nbclaudecodegui/ui/ClaudeSessionTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import com.jediterm.terminal.ui.JediTermWidget;
import io.github.nbclaudecodegui.controller.ClaudeSessionController;
import io.github.nbclaudecodegui.ui.common.BasicTextContextMenu;
import io.github.nbclaudecodegui.ui.common.Zoomable;
import io.github.nbclaudecodegui.ui.common.ZoomSupport;
import io.github.nbclaudecodegui.model.ChoiceMenuModel;
import io.github.nbclaudecodegui.model.ClaudeSessionModel;
import io.github.nbclaudecodegui.model.EditMode;
Expand All @@ -19,8 +21,12 @@
import java.awt.FlowLayout;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.KeyEventDispatcher;
import java.awt.KeyboardFocusManager;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
Expand Down Expand Up @@ -103,7 +109,7 @@
openAtStartup = false
)
public class ClaudeSessionTab extends TopComponent
implements ClaudeSessionModel.ClaudeSessionModelListener {
implements ClaudeSessionModel.ClaudeSessionModelListener, Zoomable {

private static final Logger LOG = Logger.getLogger(ClaudeSessionTab.class.getName());

Expand Down Expand Up @@ -174,7 +180,21 @@ public class ClaudeSessionTab extends TopComponent
// UI — session (created once a session starts, nulled on stop)
// -------------------------------------------------------------------------

private JediTermWidget terminalWidget;
private JediTermWidget terminalWidget;
private NetBeansSettingsProvider settingsProvider;
private int termZoomDelta =
org.openide.util.NbPreferences.forModule(ClaudeSessionTab.class)
.getInt("terminalZoomDelta", 0);
private final KeyEventDispatcher zoomKeyInterceptor = e -> {
if (e.getID() != KeyEvent.KEY_PRESSED || terminalWidget == null) return false;
if (!SwingUtilities.isDescendingFrom(e.getComponent(), terminalWidget)) return false;
if (e.getKeyCode() == KeyEvent.VK_0
&& (e.getModifiersEx() & InputEvent.CTRL_DOWN_MASK) != 0) {
SwingUtilities.invokeLater(this::resetZoom);
return true;
}
return false;
};
private JSplitPane splitPane;
private JPanel errorPanel;
JPanel southCard;
Expand Down Expand Up @@ -438,6 +458,44 @@ protected void componentActivated() {
requestFocusOnInput();
}

@Override
protected void componentShowing() {
super.componentShowing();
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.addKeyEventDispatcher(zoomKeyInterceptor);
}

@Override
protected void componentHidden() {
super.componentHidden();
KeyboardFocusManager.getCurrentKeyboardFocusManager()
.removeKeyEventDispatcher(zoomKeyInterceptor);
}

// -------------------------------------------------------------------------
// Zoomable
// -------------------------------------------------------------------------

@Override public void zoomIn() { applyTermZoom(termZoomDelta + 1); }
@Override public void zoomOut() { applyTermZoom(termZoomDelta - 1); }
@Override public void resetZoom() { applyTermZoom(0); }
@Override public int getZoomDelta() { return termZoomDelta; }
@Override public int getMinDelta() { return 6 - (int) ClaudeCodePreferences.getTerminalFontSize(); }
@Override public int getMaxDelta() { return 72 - (int) ClaudeCodePreferences.getTerminalFontSize(); }

private void applyTermZoom(int d) {
int clamped = Math.max(getMinDelta(), Math.min(getMaxDelta(), d));
termZoomDelta = clamped;
org.openide.util.NbPreferences.forModule(ClaudeSessionTab.class)
.putInt("terminalZoomDelta", clamped);
if (settingsProvider != null) {
settingsProvider.setZoomDelta(clamped);
}
if (terminalWidget instanceof ZoomableJediTermWidget zw) {
zw.refreshFont();
}
}

@Override
public boolean canClose() {
return true;
Expand Down Expand Up @@ -754,7 +812,9 @@ private void startSession(File dir, String profileName, String extraCliArgs,
(cmd, err) -> showStartError(cmd, err, model.getWorkingDirectory(), capturedConfigDir));
updateDisplayName(dir);
sessionTag = "[" + dir.getName() + "] ";
JediTermWidget widget = new JediTermWidget(new NetBeansSettingsProvider());
settingsProvider = new NetBeansSettingsProvider();
settingsProvider.setZoomDelta(termZoomDelta);
ZoomableJediTermWidget widget = new ZoomableJediTermWidget(settingsProvider, this);
Color termBg = UIManager.getColor("EditorPane.background");
if (termBg == null) termBg = UIManager.getColor("Panel.background");
if (termBg != null) {
Expand Down Expand Up @@ -842,6 +902,12 @@ public void componentResized(ComponentEvent e) {
}
});

widget.getTerminalPanel().addMouseWheelListener(
ZoomSupport.createWheelListener(this));
// The Zoom submenu is spliced into JediTerm's native context menu by
// ZoomableJediTermWidget (JediTerm ignores setComponentPopupMenu).
ZoomSupport.bindResetKey(this, this);

add(splitPane, BorderLayout.CENTER);
revalidate();
repaint();
Expand Down
59 changes: 56 additions & 3 deletions src/main/java/io/github/nbclaudecodegui/ui/MarkdownPreviewTab.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
import io.github.nbclaudecodegui.settings.ClaudeCodePreferences;
import io.github.nbclaudecodegui.ui.common.MarkdownRenderer;
import io.github.nbclaudecodegui.ui.common.UiUtils;
import io.github.nbclaudecodegui.ui.common.Zoomable;
import io.github.nbclaudecodegui.ui.common.ZoomSupport;
import io.github.nbclaudecodegui.ui.common.markdown.MarkdownFindBar;
import javax.swing.text.html.HTMLEditorKit;
import javax.swing.text.html.StyleSheet;
import org.openide.util.NbPreferences;
import java.awt.BorderLayout;
import java.awt.Toolkit;
import java.awt.datatransfer.StringSelection;
Expand Down Expand Up @@ -62,7 +67,7 @@
preferredID = "MarkdownPreviewTopComponent",
persistenceType = TopComponent.PERSISTENCE_ALWAYS
)
public class MarkdownPreviewTab extends TopComponent {
public class MarkdownPreviewTab extends TopComponent implements Zoomable {

/**
* Open live-preview tabs keyed by absolute file path.
Expand Down Expand Up @@ -113,6 +118,9 @@ public class MarkdownPreviewTab extends TopComponent {
/** Scroll ratio saved by writeExternal; applied in componentOpened() after IDE restart. */
double savedScrollRatio = 0.0;

int mdZoomDelta =
NbPreferences.forModule(MarkdownPreviewTab.class).getInt("markdownZoomDelta", 0);

/** Creates a new empty tab instance; content and title are configured by factory methods. */
MarkdownPreviewTab() {
}
Expand Down Expand Up @@ -160,11 +168,14 @@ public static void openLive(String filePath, String content, FileObject fo) {
tab.scrollPane = new JScrollPane(tab.pane);
tab.add(tab.scrollPane, BorderLayout.CENTER);

// Attach hyperlink listener, context menu, and find bar
// Attach hyperlink listener, context menu, find bar, and zoom
tab.attachHyperlinkListener();
tab.bindRefreshKey(tab.pane);
tab.pane.setComponentPopupMenu(tab.buildContextMenu());
tab.initFindBar(tab.pane);
tab.pane.addMouseWheelListener(ZoomSupport.createWheelListener(tab, true));
ZoomSupport.bindResetKey(tab.pane, tab);
tab.applyMdZoom(tab.mdZoomDelta);

if (fo != null) {
tab.fileListener = makeFileListener(tab, fo);
Expand Down Expand Up @@ -507,6 +518,40 @@ private void attachHyperlinkListener() {
});
}

// -------------------------------------------------------------------------
// Zoomable
// -------------------------------------------------------------------------

@Override public void zoomIn() { applyMdZoom(mdZoomDelta + 1); }
@Override public void zoomOut() { applyMdZoom(mdZoomDelta - 1); }
@Override public void resetZoom() { applyMdZoom(0); }
@Override public int getZoomDelta() { return mdZoomDelta; }
@Override public int getMinDelta() { return -8; }
@Override public int getMaxDelta() { return 20; }

private void applyMdZoom(int d) {
int clamped = Math.max(getMinDelta(), Math.min(getMaxDelta(), d));
mdZoomDelta = clamped;
NbPreferences.forModule(MarkdownPreviewTab.class).putInt("markdownZoomDelta", clamped);
updateZoomCss();
}

private void updateZoomCss() {
if (pane == null) return;
HTMLEditorKit kit = (HTMLEditorKit) pane.getEditorKit();
StyleSheet ss = kit.getStyleSheet();
int d = mdZoomDelta;
ss.addRule("body { font-size: " + (13 + d) + "pt; }");
ss.addRule("pre { font-size: " + (12 + d) + "pt; }");
ss.addRule("code { font-size: " + (12 + d) + "pt; }");
ss.addRule("h1 { font-size: " + (16 + d) + "pt; }");
ss.addRule("h2 { font-size: " + (14 + d) + "pt; }");
ss.addRule("h3 { font-size: " + (13 + d) + "pt; }");
ss.addRule(".info { font-size: " + (11 + d) + "pt; }");
String text = pane.getText();
pane.setText(text);
}

JPopupMenu buildContextMenu() {
JPopupMenu menu = new JPopupMenu();

Expand Down Expand Up @@ -554,6 +599,8 @@ JPopupMenu buildContextMenu() {
menu.add(forward);
menu.add(refresh);
menu.addSeparator();
menu.add(ZoomSupport.buildZoomMenu(this));
menu.addSeparator();
menu.add(find);
menu.addSeparator();
menu.add(selectAll);
Expand Down Expand Up @@ -593,8 +640,11 @@ public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
return menu;
}

private void bindRefreshKey(JEditorPane p) {
void bindRefreshKey(JEditorPane p) {
KeyStroke f5 = KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0);
// WHEN_FOCUSED overrides the NetBeans global F5 action while the pane is focused
p.getInputMap(JComponent.WHEN_FOCUSED).put(f5, "md-refresh");
// WHEN_IN_FOCUSED_WINDOW still covers F5 from elsewhere in the tab (e.g. find bar)
p.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(f5, "md-refresh");
p.getActionMap().put("md-refresh", new AbstractAction() {
@Override public void actionPerformed(ActionEvent e) { forceReload(); }
Expand Down Expand Up @@ -653,6 +703,9 @@ protected void componentOpened() {
bindRefreshKey(pane);
pane.setComponentPopupMenu(buildContextMenu());
initFindBar(pane);
pane.addMouseWheelListener(ZoomSupport.createWheelListener(this, true));
ZoomSupport.bindResetKey(pane, this);
applyMdZoom(mdZoomDelta);
}

filePath = path;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,15 @@
*/
final class NetBeansSettingsProvider extends DefaultSettingsProvider {

private int zoomDelta = 0;

void setZoomDelta(int d) {
this.zoomDelta = d;
}

@Override
public float getTerminalFontSize() {
return ClaudeCodePreferences.getTerminalFontSize();
return ClaudeCodePreferences.getTerminalFontSize() + zoomDelta;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package io.github.nbclaudecodegui.ui;

import com.jediterm.terminal.model.StyleState;
import com.jediterm.terminal.model.TerminalTextBuffer;
import com.jediterm.terminal.ui.JediTermWidget;
import com.jediterm.terminal.ui.TerminalActionProvider;
import com.jediterm.terminal.ui.TerminalPanel;
import com.jediterm.terminal.ui.settings.SettingsProvider;
import io.github.nbclaudecodegui.ui.common.Zoomable;
import io.github.nbclaudecodegui.ui.common.ZoomSupport;
import javax.swing.JPopupMenu;

/**
* A {@link JediTermWidget} that splices a "Zoom" submenu into the terminal's
* native right-click context menu.
*
* <p>JediTerm builds its context menu internally: {@code TerminalPanel} has its
* own mouse listener that calls the protected
* {@code TerminalPanel.createPopupMenu(TerminalActionProvider)} and fills it from
* the {@code TerminalActionProvider} chain. It never consults Swing's
* {@code JComponent.setComponentPopupMenu()}. Adding a Zoom item via the Swing
* popup mechanism therefore produces a <em>separate</em> single-item popup
* instead of extending the native Copy/Paste/Clear menu.
*
* <p>The only clean integration point is to subclass {@code TerminalPanel} and
* override {@code createPopupMenu}: build the native menu via {@code super}, then
* append the Zoom submenu. This widget overrides the protected
* {@link #createTerminalPanel} factory so JediTerm uses that customized panel,
* and exposes {@link #refreshFont()} so callers can re-apply the font after a
* zoom change without reflection.
*/
public class ZoomableJediTermWidget extends JediTermWidget {

private final Zoomable zoomable;
private ZoomTerminalPanel zoomPanel;

/**
* @param settingsProvider terminal settings (font, colors); also supplies the
* zoom-adjusted font size
* @param zoomable the surface whose font the context-menu Zoom submenu
* and {@link #refreshFont()} operate on
*/
public ZoomableJediTermWidget(SettingsProvider settingsProvider, Zoomable zoomable) {
super(settingsProvider);
this.zoomable = zoomable;
}

/**
* Re-applies the (possibly zoom-adjusted) font to the terminal and resizes
* the grid. Delegates to {@code TerminalPanel.reinitFontAndResize()}, which
* is {@code protected} and reachable only from within a panel subclass.
*/
public void refreshFont() {
if (zoomPanel != null) {
zoomPanel.refreshFont();
}
}

@Override
protected TerminalPanel createTerminalPanel(SettingsProvider settingsProvider,
StyleState styleState,
TerminalTextBuffer textBuffer) {
// Invoked from the JediTermWidget super-constructor (before this subclass's
// constructor body runs). 'zoomable' is read only later, lazily, from
// createPopupMenu()/refreshFont() — never here — so its not-yet-assigned
// state at this point is harmless.
zoomPanel = new ZoomTerminalPanel(settingsProvider, textBuffer, styleState);
return zoomPanel;
}

/** Terminal panel that appends the Zoom submenu to the native context menu. */
private final class ZoomTerminalPanel extends TerminalPanel {

ZoomTerminalPanel(SettingsProvider settingsProvider,
TerminalTextBuffer textBuffer,
StyleState styleState) {
super(settingsProvider, textBuffer, styleState);
}

@Override
protected JPopupMenu createPopupMenu(TerminalActionProvider provider) {
JPopupMenu menu = super.createPopupMenu(provider);
ZoomSupport.appendZoomMenu(menu, zoomable);
return menu;
}

void refreshFont() {
reinitFontAndResize();
}
}
}
Loading
Loading