diff --git a/CHANGELOG.md b/CHANGELOG.md index 64782e33..b71f69d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/lib/app.dart b/app/lib/app.dart index 57c3dcf8..b0656a15 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -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 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, diff --git a/app/lib/builtin/default_layout/src/extension.dart b/app/lib/builtin/default_layout/src/extension.dart index 5f6d311c..86737398 100644 --- a/app/lib/builtin/default_layout/src/extension.dart +++ b/app/lib/builtin/default_layout/src/extension.dart @@ -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 _openEditor(List args) async { + final ctx = _ctx; + if (ctx == null) return _notActivated(); + ctx.arrangement.openEditor(); + return IpcResponse.ok(id: '', data: {'editorOpen': true}); + } + + Future _closeEditor(List 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 _switchSidebarSection(int index) async { final ctx = _ctx; if (ctx == null) return _notActivated(); diff --git a/app/lib/kernel/src/panels/arrangement.dart b/app/lib/kernel/src/panels/arrangement.dart index d2c34437..1547c551 100644 --- a/app/lib/kernel/src/panels/arrangement.dart +++ b/app/lib/kernel/src/panels/arrangement.dart @@ -11,6 +11,9 @@ class LayoutArrangement extends ChangeNotifier { Map? _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( diff --git a/app/test/kernel/src/panels/arrangement_test.dart b/app/test/kernel/src/panels/arrangement_test.dart index 4a294d61..f5d4362d 100644 --- a/app/test/kernel/src/panels/arrangement_test.dart +++ b/app/test/kernel/src/panels/arrangement_test.dart @@ -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.15–0.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); + }); }); }