diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index ec585d35..ee818595 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -72,3 +72,5 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'parent_id', NULL, 'T-229', NULL, '2026-06-03 09:51:40', '2026-06-03 09:51:40', '2026-06-03 09:51:40', NULL, '17b018dd9f5b4c6234dad6074155f4fc', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-163', 'parent_id', 'T-132', 'T-229', NULL, '2026-06-03 09:51:40', '2026-06-03 09:51:40', '2026-06-03 09:51:40', NULL, '5dc4470123514f2886b99d983c3f14f9', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 09:54:14', '2026-06-03 09:54:14', '2026-06-03 09:54:14', NULL, 'e9fad0773bf9d930028187db8f219d9f', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-163', 'status', 'backlog', 'in_progress', NULL, '2026-06-03 10:02:02', '2026-06-03 10:02:02', '2026-06-03 10:02:02', NULL, 'a5f405a38487027074c841401ab2d45c', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'status', 'in_progress', 'done', NULL, '2026-06-03 10:02:02', '2026-06-03 10:02:02', '2026-06-03 10:02:02', NULL, 'de2818775d8d462446c341b2e74aed2e', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index bcc94672..9c56ea62 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -131,3 +131,23 @@ Acceptance: 4. Selection/caret position preserved, not just the string (nice-to-have). Implementation sketch: store the draft (text + selection) on the per-session model/controller (kernel- or session-scoped), not in the composer widget''s State. Seed the TextEditingController from it on mount and write back on change, so a remount after the interaction zone closes restores it. Relates to T-227 (focusing the composer) and the interaction-zone behavior where prompts replace the composer rather than rendering inline.', 'in_progress', 'high', NULL, NULL, NULL, '2026-06-03 09:49:35', '2026-06-03 09:54:14', NULL, '768ef9874198e50fa92f208e4ada79e0', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-163', 'task', 'T-229', 'Composer prompt history via Up/Down (Claude-CLI-style)', 'Arrow Up/Down in the Claude composer should scroll through previously-sent prompts, matching the Claude Code CLI UX: +- Up: if the caret isn''t on the first visual line, move up one line (normal editing); once on the first line, Up steps to the previous prompt in history. Down mirrors it (move down within multiline; at the last line, step to the next/newer entry). +- When entering history, stash the current draft as the newest entry so Down past the most recent restores exactly what the user was typing. +- History is the sequence of prompts submitted from this composer (this session at least; persisting across restarts is a nice-to-have). +- Plays well with the existing Enter-submit / Shift+Enter-newline and the slash typeahead (Up/Down already drive the typeahead when it''s open — history nav only applies when the typeahead is closed). + +Implementation: a small history controller + caret-line detection in ClaudeComposer (EditableText); the Up/Down handling lives alongside the existing _onKey/Shortcuts. Acceptance: send a few prompts, Up walks back through them, Down returns through them to the restored draft; multiline prompts navigate line-by-line before crossing into history; widget tests for the bound conditions (first/last line) and draft stash/restore.', 'in_progress', 'medium', NULL, NULL, NULL, '2026-05-24 09:42:44', '2026-06-03 10:02:02', NULL, '12f258c45979300ed8c0ecc52ebbe87e', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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 (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-228', 'bug', 'T-229', 'Composer draft lost when the Claude pane UI changes', 'Text typed into the Claude composer is lost whenever the pane UI changes underneath it. The clearest trigger: a permission prompt / AskUserQuestion arrives and the interaction zone replaces the composer; when it resolves and the composer returns, the half-typed message is gone. It can also drop on any pane rebuild/refresh (new transcript message, status change, tab re-render) if the draft lives only in ephemeral widget State. + +Make the draft persistent: an unsent composer message must survive UI changes and come back intact. + +Likely cause: the composer''s TextEditingController lives in widget State that is disposed/recreated when the pane rebuilds or the interaction zone swaps the composer out. The draft (text + selection) needs to be hoisted somewhere that outlives the widget. + +Acceptance: +1. Type in the composer, trigger a permission prompt (composer replaced by the interaction zone), resolve it -> the composer returns with the typed text and caret intact. +2. The draft survives an arbitrary Claude-pane rebuild (incoming transcript message, status update, sub-tab switch) without loss. +3. Draft is per-session: switching between Claude sessions/panes preserves each session''s own unsent text; sending or explicitly clearing empties it. +4. Selection/caret position preserved, not just the string (nice-to-have). + +Implementation sketch: store the draft (text + selection) on the per-session model/controller (kernel- or session-scoped), not in the composer widget''s State. Seed the TextEditingController from it on mount and write back on change, so a remount after the interaction zone closes restores it. Relates to T-227 (focusing the composer) and the interaction-zone behavior where prompts replace the composer rather than rendering inline.', 'done', 'high', NULL, NULL, NULL, '2026-06-03 09:49:35', '2026-06-03 10:02:02', NULL, 'bdd509507b199031cb2b368a2813245a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e40d13..492c89e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- Claude composer prompt history (Claude-CLI-style): Up recalls previously-sent + prompts once the caret reaches the first line, Down steps back to newer ones + and restores your in-progress draft past the newest. Per session. (T-163) - clide-hosted Claude sessions are now bootstrapped to drive the IDE: each spawned session gets `CLIDE_SOCK`/`CLIDE_WORKSPACE` in its env and `clide` on its PATH, a system-prompt note telling it it is inside clide and how to use diff --git a/lib/builtin/claude/src/claude_composer.dart b/lib/builtin/claude/src/claude_composer.dart index 15d5a68a..e9781afa 100644 --- a/lib/builtin/claude/src/claude_composer.dart +++ b/lib/builtin/claude/src/claude_composer.dart @@ -50,6 +50,7 @@ class ClaudeComposer extends StatefulWidget { this.busy = false, this.initialValue, this.onDraftChanged, + this.history = const [], }); /// Called with the composed message (typed text plus attachment `@path` @@ -86,6 +87,11 @@ class ClaudeComposer extends StatefulWidget { /// the draft. Fires with an empty value on submit/clear (T-228). final ValueChanged? onDraftChanged; + /// Previously-submitted prompts for this session, oldest-first. Up/Down + /// walk this when the caret is on the first/last line (T-163). Owned by + /// the pane (per session); the composer only reads it. + final List history; + @override State createState() => _ClaudeComposerState(); } @@ -102,6 +108,15 @@ class _ClaudeComposerState extends State { List _suggestions = const []; int _selected = 0; + // Prompt-history navigation (T-163). _historyIndex is null when editing + // the live draft; otherwise it indexes [widget.history]. _stash holds the + // draft we were typing before stepping into history, restored on stepping + // back past the newest entry. _applyingHistory suppresses the draft report + // so previewing history entries doesn't overwrite the persisted draft. + int? _historyIndex; + TextEditingValue? _stash; + bool _applyingHistory = false; + @override void initState() { super.initState(); @@ -127,7 +142,13 @@ class _ClaudeComposerState extends State { void _onTextChanged() { setState(() {}); - widget.onDraftChanged?.call(_controller.value); + if (!_applyingHistory) { + // A real edit (user typed) while previewing history commits to it — + // leave navigation and treat the text as the new draft. + _historyIndex = null; + _stash = null; + widget.onDraftChanged?.call(_controller.value); + } _syncTypeahead(); } @@ -208,7 +229,23 @@ class _ClaudeComposerState extends State { } return KeyEventResult.ignored; } - if (_overlay == null) return KeyEventResult.ignored; + if (_overlay == null) { + // Typeahead closed → Up/Down recall prompt history, but only once the + // caret reaches the first/last line so multi-line editing still works + // line-by-line first (T-163, Claude-CLI-style). + switch (e.logicalKey) { + case LogicalKeyboardKey.arrowUp: + if (_caretOnFirstLine() && _historyPrev()) return KeyEventResult.handled; + return KeyEventResult.ignored; + case LogicalKeyboardKey.arrowDown: + if (_historyIndex != null && _caretOnLastLine()) { + _historyNext(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + return KeyEventResult.ignored; + } switch (e.logicalKey) { case LogicalKeyboardKey.arrowDown: _moveSelection(1); @@ -225,6 +262,62 @@ class _ClaudeComposerState extends State { return KeyEventResult.ignored; } + // -- Prompt history (T-163) ------------------------------------------------- + + bool _caretOnFirstLine() { + final sel = _controller.selection; + if (!sel.isCollapsed || sel.baseOffset < 0) return false; + return !_controller.text.substring(0, sel.baseOffset).contains('\n'); + } + + bool _caretOnLastLine() { + final sel = _controller.selection; + if (!sel.isCollapsed || sel.baseOffset < 0) return false; + return !_controller.text.substring(sel.baseOffset).contains('\n'); + } + + /// Step to the previous (older) history entry, stashing the live draft on + /// first entry. Returns false only when there's no history to walk. + bool _historyPrev() { + final h = widget.history; + if (h.isEmpty) return false; + if (_historyIndex == null) { + _stash = _controller.value; + _historyIndex = h.length - 1; + } else if (_historyIndex! > 0) { + _historyIndex = _historyIndex! - 1; + } + // else: already at the oldest — consume the key, stay put. + _applyHistory(h[_historyIndex!]); + return true; + } + + /// Step to the next (newer) entry; stepping past the newest restores the + /// stashed draft and leaves history navigation. + void _historyNext() { + final h = widget.history; + final idx = _historyIndex; + if (idx == null) return; + if (idx < h.length - 1) { + _historyIndex = idx + 1; + _applyHistory(h[_historyIndex!]); + } else { + _historyIndex = null; + _applyValue(_stash ?? const TextEditingValue()); + _stash = null; + } + } + + void _applyHistory(String text) => _applyValue(TextEditingValue(text: text, selection: TextSelection.collapsed(offset: text.length))); + + /// Set the controller without it being treated as a user edit (so the + /// preview doesn't overwrite the persisted draft or exit navigation). + void _applyValue(TextEditingValue v) { + _applyingHistory = true; + _controller.value = v; + _applyingHistory = false; + } + Widget _buildTypeahead(BuildContext ctx) { final theme = ClideTheme.of(ctx).surface; return Positioned( diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index 87fc64c1..2cc984c3 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -84,6 +84,10 @@ class _ClaudePaneState extends State { /// when the message is sent. final Map _drafts = {}; + /// Per-session submitted-prompt history (oldest-first) for Up/Down recall + /// in the composer (T-163). Keyed by claude session id. + final Map> _history = {}; + /// This pane's stable key in the session orchestrator (T-169). String get _orchId => widget.isPrimary ? 'primary' : 'secondary-${widget.secondaryIndex}'; @@ -249,6 +253,7 @@ class _ClaudePaneState extends State { // /resume picks a past session and re-binds to it. /fork branches the // conversation into a new pane (T-172). void _send(String text) { + _appendHistory(text); switch (clideOwnedCommand(text)) { case 'clear': unawaited(_clearSession()); @@ -263,6 +268,15 @@ class _ClaudePaneState extends State { _session?.send(text); } + /// Record a submitted prompt in the active session's history (T-163), + /// de-duping immediate repeats. Empty/whitespace prompts are skipped. + void _appendHistory(String text) { + final id = _sessionId; + if (id == null || text.trim().isEmpty) return; + final list = _history.putIfAbsent(id, () => []); + if (list.isEmpty || list.last != text) list.add(text); + } + /// Persist (or clear) the composer draft for the active session (T-228). /// The composer reports an empty value on submit/clear, which drops the /// entry so a sent message doesn't reappear. @@ -396,6 +410,7 @@ class _ClaudePaneState extends State { pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()), initialValue: _sessionId == null ? null : _drafts[_sessionId], onDraftChanged: _onDraftChanged, + history: _sessionId == null ? const [] : (_history[_sessionId] ?? const []), ), ), ], diff --git a/test/builtin/claude/claude_composer_test.dart b/test/builtin/claude/claude_composer_test.dart index ad94186a..b29e9eff 100644 --- a/test/builtin/claude/claude_composer_test.dart +++ b/test/builtin/claude/claude_composer_test.dart @@ -336,6 +336,91 @@ void main() { }); }); + group('ClaudeComposer prompt history (T-163)', () { + late KernelFixture f; + setUp(() async => f = await KernelFixture.create()); + tearDown(() => f.dispose()); + + String text(WidgetTester tester) => tester.widget(find.byType(EditableText)).controller.text; + + Future pumpWithHistory( + WidgetTester tester, { + required List history, + ValueChanged? onDraftChanged, + }) async { + await tester.pumpWidget(harness( + f, + ClaudeComposer(onSubmit: (_) {}, history: history, onDraftChanged: onDraftChanged), + )); + await tester.tap(find.byType(EditableText)); + await tester.pump(); + } + + testWidgets('Up walks back through history, Down walks forward', (tester) async { + await pumpWithHistory(tester, history: ['first', 'second', 'third']); + await tester.enterText(find.byType(EditableText), 'wip'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(text(tester), 'third'); // newest first + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(text(tester), 'second'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(text(tester), 'third'); + }); + + testWidgets('Down past the newest entry restores the in-progress draft', (tester) async { + await pumpWithHistory(tester, history: ['old']); + await tester.enterText(find.byType(EditableText), 'my draft'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // → 'old' + await tester.pump(); + expect(text(tester), 'old'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); // past newest → restore + await tester.pump(); + expect(text(tester), 'my draft'); + }); + + testWidgets('Up does nothing when there is no history', (tester) async { + await pumpWithHistory(tester, history: const []); + await tester.enterText(find.byType(EditableText), 'solo'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(text(tester), 'solo'); + }); + + testWidgets('Up off the first line does not recall (multiline edits first)', (tester) async { + await pumpWithHistory(tester, history: ['recalled']); + // Caret ends up on the last line of a two-line draft. + await tester.enterText(find.byType(EditableText), 'line one\nline two'); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + expect(text(tester), 'line one\nline two'); // unchanged — no recall + }); + + testWidgets('previewing history does not overwrite the persisted draft', (tester) async { + final drafts = []; + await pumpWithHistory(tester, history: ['past'], onDraftChanged: drafts.add); + await tester.enterText(find.byType(EditableText), 'keep me'); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); // preview 'past' + await tester.pump(); + + // The last *persisted* draft is still the user's text, not the preview. + expect(drafts.last.text, 'keep me'); + }); + }); + group('ClaudeComposer draft persistence (T-228)', () { late KernelFixture f; setUp(() async => f = await KernelFixture.create());