implement editor-above-Claude split in workspace

Editor opens above Claude via Ctrl+E with a draggable divider at
35% height (clamped 15-70%). Ctrl+W or Escape closes it. The
workspace no longer renders a tab bar — Claude is the always-visible
primary surface per D-047/D-048; the editor is a split overlay
per D-049, not a tab. The _WorkspaceSlot separates editor from
primary content tabs and renders them as a vertical split when the
editor is open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 23:01:02 +02:00
co-authored by Claude Opus 4.6
parent 3f19eca059
commit 43901bc387
5 changed files with 207 additions and 0 deletions
+11
View File
@@ -40,6 +40,17 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
- Right panel (context) icon rail — bottom section switcher matching
the left sidebar rail pattern (D-047, T-034).
- Editor-above-Claude mode — `Ctrl+E` opens the editor as a split
above Claude in the middle column with a draggable divider;
`Ctrl+W` or `Escape` closes it. Prompt bar Y stays fixed
(D-049, T-035).
### Changed
- Workspace renders Claude as the always-visible primary surface
instead of showing a tab bar (D-047, D-048). The editor is a
split overlay, not a tab.
- Syntax highlighting via tree-sitter (dart:ffi to vendored
libtree-sitter.so with embedded wasmtime). 48 grammar WASM files,
48 highlight queries. Colors map to theme syntax tokens.
+94
View File
@@ -228,6 +228,10 @@ class SlotHost extends StatelessWidget {
);
}
if (slot == Slots.workspace) {
return _WorkspaceSlot(tabs: tabs, active: active);
}
return Container(
color: tokens.panelBackground,
child: Column(
@@ -310,6 +314,96 @@ class _SidebarSlot extends StatelessWidget {
}
}
class _WorkspaceSlot extends StatelessWidget {
const _WorkspaceSlot({required this.tabs, required this.active});
final List<TabContribution> tabs;
final TabContribution active;
static const _editorTabId = 'editor.active';
@override
Widget build(BuildContext context) {
final kernel = ClideKernel.of(context);
final tokens = ClideTheme.of(context).surface;
return ListenableBuilder(
listenable: kernel.arrangement,
builder: (ctx, _) {
final editorOpen = kernel.arrangement.editorOpen;
final editorTab = tabs.where((t) => t.id == _editorTabId).firstOrNull;
final primaryTabs = tabs.where((t) => t.id != _editorTabId).toList();
final primary = primaryTabs.contains(active) ? active : (primaryTabs.isNotEmpty ? primaryTabs.first : active);
if (!editorOpen || editorTab == null) {
return Container(color: tokens.panelBackground, child: primary.build(ctx));
}
final ratio = kernel.arrangement.editorRatio;
return Container(
color: tokens.panelBackground,
child: LayoutBuilder(
builder: (ctx, constraints) {
final totalHeight = constraints.maxHeight;
final editorHeight = (totalHeight * ratio).clamp(60.0, totalHeight - 60.0);
return Column(
children: [
SizedBox(height: editorHeight, child: editorTab.build(ctx)),
_EditorDragHandle(arrangement: kernel.arrangement, totalHeight: totalHeight),
Expanded(child: primary.build(ctx)),
],
);
},
),
);
},
);
}
}
class _EditorDragHandle extends StatefulWidget {
const _EditorDragHandle({required this.arrangement, required this.totalHeight});
final LayoutArrangement arrangement;
final double totalHeight;
@override
State<_EditorDragHandle> createState() => _EditorDragHandleState();
}
class _EditorDragHandleState extends State<_EditorDragHandle> {
bool _hovered = false;
double? _dragStartRatio;
double? _dragStartY;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return MouseRegion(
cursor: SystemMouseCursors.resizeRow,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: Listener(
onPointerDown: (e) {
_dragStartRatio = widget.arrangement.editorRatio;
_dragStartY = e.position.dy;
},
onPointerMove: (e) {
final startR = _dragStartRatio;
final startY = _dragStartY;
if (startR == null || startY == null || widget.totalHeight <= 0) return;
final deltaRatio = (e.position.dy - startY) / widget.totalHeight;
widget.arrangement.setEditorRatio(startR + deltaRatio);
},
onPointerUp: (_) {
_dragStartRatio = null;
_dragStartY = null;
},
child: Container(height: 4, color: _hovered ? tokens.panelActiveBorder : tokens.panelBorder),
),
);
}
}
class _ContextSlot extends StatelessWidget {
const _ContextSlot({
required this.tabs,
@@ -81,6 +81,21 @@ class DefaultLayoutExtension extends ClideExtension {
defaultBinding: 'escape',
run: _exitFocusMode,
),
// Editor split (D-049, D-054)
CommandContribution(
id: 'editor.open',
command: 'editor.open',
title: 'Open Editor',
defaultBinding: 'ctrl+e',
run: _openEditor,
),
CommandContribution(
id: 'editor.close',
command: 'editor.close',
title: 'Close Editor',
defaultBinding: 'ctrl+w',
run: _closeEditor,
),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
@@ -182,6 +197,10 @@ class DefaultLayoutExtension extends ClideExtension {
ctx.arrangement.exitFocusMode();
return IpcResponse.ok(id: '', data: {'focusMode': false});
}
if (ctx.arrangement.editorOpen) {
ctx.arrangement.closeEditor();
return IpcResponse.ok(id: '', data: {'editorOpen': false});
}
if (ctx.palette.isOpen) {
ctx.palette.toggle();
return IpcResponse.ok(id: '', data: {'palette': false});
@@ -189,6 +208,23 @@ class DefaultLayoutExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {});
}
Future<IpcResponse> _openEditor(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
ctx.arrangement.openEditor();
return IpcResponse.ok(id: '', data: {'editorOpen': true});
}
Future<IpcResponse> _closeEditor(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
if (ctx.arrangement.editorOpen) {
ctx.arrangement.closeEditor();
return IpcResponse.ok(id: '', data: {'editorOpen': false});
}
return IpcResponse.ok(id: '', data: {});
}
Future<IpcResponse> _switchSidebarSection(int index) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
@@ -11,6 +11,9 @@ class LayoutArrangement extends ChangeNotifier {
Map<SlotId, _SlotState>? _focusModeSnapshot;
SlotId? _focusModeSlot;
bool _editorOpen = false;
double _editorRatio = 0.35;
void applyPreset(LayoutPresetContribution preset) {
_state.clear();
_focusModeSnapshot = null;
@@ -37,6 +40,8 @@ class LayoutArrangement extends ChangeNotifier {
bool isCollapsed(SlotId id) => _state[id]?.collapsed ?? false;
bool get isInFocusMode => _focusModeSlot != null;
SlotId? get focusModeSlot => _focusModeSlot;
bool get editorOpen => _editorOpen;
double get editorRatio => _editorRatio;
void setSize(SlotId id, double size) {
final s = _state[id];
@@ -100,6 +105,30 @@ class LayoutArrangement extends ChangeNotifier {
}
}
void openEditor() {
if (_editorOpen) return;
_editorOpen = true;
notifyListeners();
}
void closeEditor() {
if (!_editorOpen) return;
_editorOpen = false;
notifyListeners();
}
void toggleEditor() {
_editorOpen = !_editorOpen;
notifyListeners();
}
void setEditorRatio(double ratio) {
final clamped = ratio.clamp(0.15, 0.70);
if (_editorRatio == clamped) return;
_editorRatio = clamped;
notifyListeners();
}
void registerSlotsInto(PanelRegistry registry, LayoutPresetContribution preset) {
for (final slot in preset.slots) {
registry.registerSlot(SlotDefinition(
@@ -100,5 +100,42 @@ void main() {
a.applyPreset(classicPreset());
expect(a.isInFocusMode, false);
});
test('openEditor sets editorOpen', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
expect(a.editorOpen, false);
a.openEditor();
expect(a.editorOpen, true);
});
test('closeEditor clears editorOpen', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.openEditor();
a.closeEditor();
expect(a.editorOpen, false);
});
test('toggleEditor flips editorOpen', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.toggleEditor();
expect(a.editorOpen, true);
a.toggleEditor();
expect(a.editorOpen, false);
});
test('setEditorRatio clamps to 0.150.70', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
a.setEditorRatio(0.05);
expect(a.editorRatio, 0.15);
a.setEditorRatio(0.90);
expect(a.editorRatio, 0.70);
a.setEditorRatio(0.40);
expect(a.editorRatio, 0.40);
});
test('editorRatio defaults to 0.35', () {
final a = LayoutArrangement()..applyPreset(classicPreset());
expect(a.editorRatio, 0.35);
});
});
}