workspace tab cycle commands + ctrl+pagedown/up (T-405 part 1)

Add workspace.tab.next / workspace.tab.previous commands that cycle the
Slots.workspace tab strip with wraparound (no-op under two tabs), bound
ctrl+pagedown / ctrl+pageup across every preset via defaultBindings. Single-
chord, so no global matcher needed. Activating a tab also focuses the
workspace slot.

Part 2 (vim gt/gT) is deferred — it needs the global multi-chord matcher
T-404 introduces. T-405 stays open for that follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 18:06:36 +02:00
co-authored by Claude Opus 4.8
parent 054eaf6cd4
commit 5a21ea949a
5 changed files with 95 additions and 0 deletions
+1
View File
@@ -244,3 +244,4 @@ INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ4BYD4STCKCY8JNKF23Q4W', 'T-417', '2026-06-12 11:22:15', '2026-06-12 11:22:15', NULL, '15aa9b25417162126cbcde174d3537da', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBQ595H08JFTRFSR90GSZQ0G', 'T-418', '2026-06-12 11:26:14', '2026-06-12 11:26:14', NULL, '000e07ae64b08273a2d2d9f8a77d193f', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FBTTMGKSYMTF8M1KQWTG774W', 'T-419', '2026-06-12 19:58:58', '2026-06-12 19:58:58', NULL, 'd2b01a2c3d1ce24cc863ac6d9d814d3d', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FC2XY1T85A65YY9SG25VVEY4', 'T-420', '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '16ea4c9353a56798b894ab3d85fb7b56', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > ticket_idmap.updated_at OR (excluded.updated_at = ticket_idmap.updated_at AND excluded.hash > ticket_idmap.hash);
+12
View File
@@ -5336,3 +5336,15 @@ OPEN QUESTIONS:
- File-tree selection needs a flat index over a recursive, lazily-loaded widget tree (_Children recursion). Confirm the cursor model lives in FileTreeController (flattening _expanded + entriesFor) vs recomputed in the view affects testability + scroll-into-view.
- Conversation uses ListView.builder with grouped/coalesced items; j/k "line scroll" is pixel-offset, not item selection. Confirm pixel-scroll (reader-pane semantic) is intended vs card-by-card selection.
- Should focusing a pane via F6/ctrl+1..3 (FocusTracker.focusSlot) also focus the inner nav handler so j/k work immediately, or must the user click in first?', 'done', 'medium', NULL, NULL, NULL, '2026-06-12 03:21:49', '2026-06-13 12:45:43', NULL, '5d7f59ef58611f7fe6c8a4cb3e755154', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FC2XY1T85A65YY9SG25VVEY4', 'bug', '06FBDSJYQFDNKP4KA1JAEDSS8W', 'Worktree-safe git hooks + WorktreeCreate bootstrap (pql hooks break git worktree add)', 'PROBLEM (diagnosed 2026-06-13): `pql init` installs .githooks/post-checkout that unconditionally sources an UNTRACKED .pql/hooks/post-checkout (which does `pql plan rebuild` on branch checkout). A fresh `git worktree add` checks out only TRACKED files, so that hook is absent in every new worktree → the `.` source fails and the post-checkout hook exits non-zero, which `git worktree add` propagates as a hard failure. This breaks Claude Code agent isolation:''worktree'' (and any clide worktree flow) in EVERY clide repo that ran pql init — and, because the bug is in pql''s init template, in every pql-using repo, not just clide.
REPRO: `git worktree add <path> -b <branch> HEAD` fails with exit 1 from .githooks/post-checkout (the source of the absent untracked hook). Confirmed both sequential and concurrent.
REPO-LOCAL FIX (DONE commits 008779c then 054eaf6 on main): .githooks/post-checkout now guards with `if [ -f "$hook" ]; then . "$hook"; fi` and forces `exit 0` (post-checkout is best-effort and must never abort a checkout/worktree). NB: the first attempt `[ -f x ] && . x` was itself buggy returns 1 when the file is absent (the script''s last statement), still aborting.
PERMANENT FIX (this ticket clide repo AND clide-the-product, since clide ships to other devs and owns workspace onboarding per T-354):
1. FIX THE TEMPLATE AT SOURCE: the worktree-safe hook (if-guard + exit 0) must be what gets INSTALLED, not a one-off patch. As clide internalizes pql (T-354/T-355), clide should own/patch the post-checkout hook install so every clide-managed workspace is worktree-safe. ALSO report upstream to pql its init hook template has this latent bug for all pql users.
2. WORKTREE BOOTSTRAP via a WorktreeCreate hook: ship a .claude/settings.json `WorktreeCreate` hook (verified real Claude Code hook fires on harness worktree creation for --worktree / EnterWorktree / agent isolation; receives {hook_event_name,cwd,name} on stdin; MUST print the new worktree''s absolute path on stdout) that copies the local .pql/hooks/ into the new worktree and runs `pql plan rebuild` so pql state is correct in agent worktrees (the guarded git hook only stops the ABORT; it doesn''t make the rebuild happen in the worktree). Pair with `WorktreeRemove` for teardown. Known caveats / open CC issues: #36205 (EnterWorktree ignores these hooks), #39281 (--worktree --tmux skips them) — so the git-hook safety net in (1) is still needed.
3. baseRef: settings schema `worktree.baseRef` defaults to ''fresh'' (branches worktrees from origin/<default-branch>), so UNPUSHED local commits are absent in agent worktrees which silently breaks foundation-first agent fan-outs (observed: 2 of 3 agents branched from origin/main without the local foundation). For clide''s commit-locally / batch-push dev flow, set `worktree.baseRef: "head"` in clide''s .claude/settings.json (or document the tradeoff).
FILES: .githooks/post-checkout (repo fix done); .claude/settings.json (add WorktreeCreate/WorktreeRemove hooks + worktree.baseRef:head); the pql-init / onboarding path clide will own (T-354/T-355); docs/CONTRIBUTING. Upstream: file a pql issue/PR for the init hook template.', 'backlog', 'high', NULL, NULL, NULL, '2026-06-13 14:51:51', '2026-06-13 14:51:51', NULL, '8c0ffbe33ea5f675ac6bd1c1183e9afa', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+3
View File
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- **Workspace tab cycling with ctrl+pagedown / ctrl+pageup.** New
`workspace.tab.next` / `workspace.tab.previous` commands cycle the workspace
tab strip with wraparound, bound across every preset. (T-405)
- **Vim normal-mode navigation works outside the editor.** Under the vim preset,
a focused file tree or conversation now responds to j/k, ctrl+d/ctrl+u, gg/G,
and (tree) h/l/o — a selection cursor in the tree, scrolling in the
@@ -53,6 +53,23 @@ class DefaultLayoutExtension extends ClideExtension {
// 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),
// Workspace tab cycling (T-405). Preset-neutral ctrl+pagedown/up across every
// preset; the vim preset additionally binds gt/gT to these (T-405 part 2,
// once a global multi-chord matcher lands — see T-404).
CommandContribution(
id: 'workspace.tab.next',
command: 'workspace.tab.next',
title: 'Next Workspace Tab',
defaultBinding: 'ctrl+pagedown',
run: _nextWorkspaceTab,
),
CommandContribution(
id: 'workspace.tab.previous',
command: 'workspace.tab.previous',
title: 'Previous Workspace Tab',
defaultBinding: 'ctrl+pageup',
run: _prevWorkspaceTab,
),
// Sidebar section switching (D-054): alt+1 through alt+5
for (var i = 0; i < 5; i++)
CommandContribution(
@@ -195,6 +212,27 @@ class DefaultLayoutExtension extends ClideExtension {
return IpcResponse.ok(id: '', data: {'focused': 'workspace'});
}
Future<IpcResponse> _nextWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: true);
Future<IpcResponse> _prevWorkspaceTab(List<String> args) => _cycleWorkspaceTab(forward: false);
/// Cycle the workspace tab strip with wraparound (T-405). A no-op when there
/// are fewer than two tabs. Activating a tab also focuses the workspace slot
/// so the newly-shown pane takes keyboard focus.
Future<IpcResponse> _cycleWorkspaceTab({required bool forward}) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
final tabs = ctx.panels.tabsFor(Slots.workspace);
if (tabs.length < 2) return IpcResponse.ok(id: '', data: const {'cycled': false});
final active = ctx.panels.activeTabIn(Slots.workspace);
final cur = tabs.indexWhere((t) => t.id == active);
final start = cur < 0 ? 0 : cur;
final next = (start + (forward ? 1 : -1) + tabs.length) % tabs.length;
final nextId = tabs[next].id;
ctx.panels.activateTab(Slots.workspace, nextId);
ctx.focus.setActive(slot: Slots.workspace, contributionId: nextId);
return IpcResponse.ok(id: '', data: {'active': nextId});
}
Future<IpcResponse> _focusRight(List<String> args) async {
final ctx = _ctx;
if (ctx == null) return _notActivated();
@@ -1,6 +1,7 @@
import 'package:clide/builtin/default_layout/default_layout.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
@@ -151,5 +152,45 @@ void main() {
// Sidebar auto-expanded.
expect(f.services.arrangement.isCollapsed(Slots.sidebar), isFalse);
});
test('workspace.tab.next/previous cycle the workspace tabs with wraparound (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final panels = f.services.panels;
for (final id in ['wt.a', 'wt.b', 'wt.c']) {
panels.contribute(TabContribution(id: id, slot: Slots.workspace, title: id, build: (_) => const SizedBox.shrink()));
}
panels.setTabOrder(Slots.workspace, ['wt.a', 'wt.b', 'wt.c']);
panels.activateTab(Slots.workspace, 'wt.a');
await f.services.commands.execute('workspace.tab.next');
expect(panels.activeTabIn(Slots.workspace), 'wt.b');
await f.services.commands.execute('workspace.tab.next');
expect(panels.activeTabIn(Slots.workspace), 'wt.c');
await f.services.commands.execute('workspace.tab.next'); // wrap forward
expect(panels.activeTabIn(Slots.workspace), 'wt.a');
await f.services.commands.execute('workspace.tab.previous'); // wrap backward
expect(panels.activeTabIn(Slots.workspace), 'wt.c');
});
test('ctrl+pagedown/up resolve to the workspace tab-cycle commands across presets (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final km = f.services.keymap.keymap;
expect((km?.resolve(KeyChord.parse('ctrl+pagedown'), const {}) as InvokeCommandIntent?)?.commandId, 'workspace.tab.next');
expect((km?.resolve(KeyChord.parse('ctrl+pageup'), const {}) as InvokeCommandIntent?)?.commandId, 'workspace.tab.previous');
});
test('workspace tab cycle is a no-op with fewer than two tabs (T-405)', () async {
f.services.extensions.register(DefaultLayoutExtension());
await f.services.extensions.activateAll();
final panels = f.services.panels;
panels.contribute(TabContribution(id: 'only', slot: Slots.workspace, title: 'only', build: (_) => const SizedBox.shrink()));
panels.activateTab(Slots.workspace, 'only');
final r = await f.services.commands.execute('workspace.tab.next');
expect(r.ok, isTrue);
expect(r.data['cycled'], isFalse);
expect(panels.activeTabIn(Slots.workspace), 'only');
});
});
}