add Up/Down prompt history to the Claude composer

T-163. Up/Down walk previously-sent prompts, Claude-CLI-style: Up steps
to older entries only once the caret is on the first line (so multi-line
edits move line-by-line first), Down returns toward newer ones and, past
the newest, restores the in-progress draft that was stashed on entry.

History is per-session, owned by the pane (appended on submit, deduping
immediate repeats) and read by the composer. Previewing entries is
suppressed from the draft report, so browsing history never overwrites
the persisted draft (T-228) — and a remount mid-browse restores the real
draft, not a preview. History nav only applies when the slash typeahead
is closed; Up/Down still drive the typeahead when it's open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 12:04:59 +02:00
co-authored by Claude Opus 4.8
parent b4a02dc533
commit ab5d63debd
6 changed files with 220 additions and 2 deletions
@@ -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;
+20
View File
@@ -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);
+3
View File
@@ -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
+95 -2
View File
@@ -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<TextEditingValue>? 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<String> history;
@override
State<ClaudeComposer> createState() => _ClaudeComposerState();
}
@@ -102,6 +108,15 @@ class _ClaudeComposerState extends State<ClaudeComposer> {
List<String> _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<ClaudeComposer> {
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<ClaudeComposer> {
}
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<ClaudeComposer> {
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(
+15
View File
@@ -84,6 +84,10 @@ class _ClaudePaneState extends State<ClaudePane> {
/// when the message is sent.
final Map<String, TextEditingValue> _drafts = {};
/// Per-session submitted-prompt history (oldest-first) for Up/Down recall
/// in the composer (T-163). Keyed by claude session id.
final Map<String, List<String>> _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<ClaudePane> {
// /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<ClaudePane> {
_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, () => <String>[]);
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<ClaudePane> {
pasteResolver: () => resolveClipboardAttachment(const NativeClipboard()),
initialValue: _sessionId == null ? null : _drafts[_sessionId],
onDraftChanged: _onDraftChanged,
history: _sessionId == null ? const [] : (_history[_sessionId] ?? const []),
),
),
],
@@ -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<EditableText>(find.byType(EditableText)).controller.text;
Future<void> pumpWithHistory(
WidgetTester tester, {
required List<String> history,
ValueChanged<TextEditingValue>? 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 = <TextEditingValue>[];
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());