diff --git a/CHANGELOG.md b/CHANGELOG.md index 294ed781a..0be6cdfb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Only write entries that are worth mentioning to users. ## Unreleased +- Shell: Add tab-style navigation for multi-question panels — use Left/Right arrows or Tab to switch between questions, with visual indicators for answered, current, and pending states, and automatic state restoration when revisiting a question +- Shell: Allow Space key to submit single-select questions in the question panel +- Web: Add tab-style navigation for multi-question dialogs with clickable tab bar, keyboard navigation, and state restoration when revisiting a question - Core: Set process title to "Kimi Code" (visible in `ps` / Activity Monitor / terminal tab title) and label web worker subprocesses as "kimi-code-worker" ## 1.14.0 (2026-02-26) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 049c0677e..5cbcd6f12 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -4,6 +4,9 @@ This page documents the changes in each Kimi Code CLI release. ## Unreleased +- Shell: Add tab-style navigation for multi-question panels — use Left/Right arrows or Tab to switch between questions, with visual indicators for answered, current, and pending states, and automatic state restoration when revisiting a question +- Shell: Allow Space key to submit single-select questions in the question panel +- Web: Add tab-style navigation for multi-question dialogs with clickable tab bar, keyboard navigation, and state restoration when revisiting a question - Core: Set process title to "Kimi Code" (visible in `ps` / Activity Monitor / terminal tab title) and label web worker subprocesses as "kimi-code-worker" ## 1.14.0 (2026-02-26) diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 4336e6adf..eb6d0ba84 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -4,6 +4,9 @@ ## 未发布 +- Shell:为多问题面板添加标签式导航——使用左右方向键或 Tab 键在问题间切换,并以可视化指示器区分已答、当前和待答状态,重新访问已答问题时自动恢复选择状态 +- Shell:在问题面板中支持使用空格键提交单选问题 +- Web:为多问题对话框添加标签式导航,支持可点击标签栏、键盘导航,以及重新访问已答问题时恢复选择状态 - Core:将进程标题设置为 "Kimi Code"(在 `ps` / 活动监视器 / 终端标签页标题中可见),并将 Web Worker 子进程标记为 "kimi-code-worker" ## 1.14.0 (2026-02-26) diff --git a/src/kimi_cli/ui/shell/visualize.py b/src/kimi_cli/ui/shell/visualize.py index e69aca7ac..c0c4f7106 100644 --- a/src/kimi_cli/ui/shell/visualize.py +++ b/src/kimi_cli/ui/shell/visualize.py @@ -451,6 +451,7 @@ def __init__(self, request: QuestionRequest): self.request = request self._current_question_index = 0 self._answers: dict[str, str] = {} + self._saved_selections: dict[int, tuple[int, set[int]]] = {} self._selected_index = 0 self._multi_selected: set[int] = set() self._setup_current_question() @@ -459,8 +460,36 @@ def _setup_current_question(self) -> None: q = self._current_question self._options = [(o.label, o.description) for o in q.options] self._options.append((OTHER_OPTION_LABEL, "Provide custom text input")) - self._selected_index = 0 - self._multi_selected = set() + idx = self._current_question_index + if idx in self._saved_selections: + saved_idx, saved_multi = self._saved_selections[idx] + self._selected_index = min(saved_idx, len(self._options) - 1) + self._multi_selected = saved_multi + elif q.question in self._answers: + answer = self._answers[q.question] + if q.multi_select: + answer_labels = [a.strip() for a in answer.split(", ")] + known_labels = {label for label, _ in self._options[:-1]} + self._multi_selected = set() + for i, (label, _) in enumerate(self._options[:-1]): + if label in answer_labels: + self._multi_selected.add(i) + # Unmatched labels = Other text + if any(answer_label not in known_labels for answer_label in answer_labels): + self._multi_selected.add(len(self._options) - 1) + self._selected_index = min(self._multi_selected) if self._multi_selected else 0 + else: + for i, (label, _) in enumerate(self._options): + if label == answer: + self._selected_index = i + break + else: + # Unknown submitted label should map to the synthetic "Other" option. + self._selected_index = len(self._options) - 1 + self._multi_selected = set() + else: + self._selected_index = 0 + self._multi_selected = set() @property def _current_question(self): @@ -489,10 +518,22 @@ def render(self) -> RenderableType: q = self._current_question lines: list[RenderableType] = [] - # Header + question - header = f" {q.header} " if q.header else "" - if header: - lines.append(Text.from_markup(f"[bold cyan]{header}[/bold cyan]")) + # Tab bar for multi-question navigation + if len(self.request.questions) > 1: + tab_parts: list[str] = [] + for i, qi in enumerate(self.request.questions): + label = escape(qi.header or f"Q{i + 1}") + if i == self._current_question_index: + icon, style = "\u25cf", "bold cyan" + elif qi.question in self._answers: + icon, style = "\u2713", "green" + else: + icon, style = "\u25cb", "grey50" + tab_parts.append(f"[{style}]({icon}) {label}[/{style}]") + lines.append(Text.from_markup(" ".join(tab_parts))) + lines.append(Text("")) + + # Question text (header is now shown in the tab bar) lines.append(Text.from_markup(f"[yellow]? {escape(q.question)}[/yellow]")) if q.multi_select: lines.append(Text(" (SPACE to toggle, ENTER to submit)", style="dim italic")) @@ -501,8 +542,8 @@ def render(self) -> RenderableType: # Options for i, (label, description) in enumerate(self._options): if q.multi_select: - checked = "x" if i in self._multi_selected else " " - prefix = f"[{checked}]" + checked = "\u2713" if i in self._multi_selected else " " + prefix = f"\\[{checked}]" if i == self._selected_index: option_line = Text.from_markup(f"[cyan]{prefix} {escape(label)}[/cyan]") else: @@ -517,19 +558,43 @@ def render(self) -> RenderableType: if description and label != OTHER_OPTION_LABEL: lines.append(Text(f" {description}", style="dim")) - # Progress indicator for multi-question + # Keyboard hint for multi-question if len(self.request.questions) > 1: lines.append(Text("")) lines.append( Text( - f" Question {self._current_question_index + 1}" - f" of {len(self.request.questions)}", + " \u25c4/\u25ba switch question " + "\u25b2/\u25bc select \u21b5 submit esc exit", style="dim", ) ) return Padding(Group(*lines), 1) + def go_to(self, index: int) -> None: + """Jump to a specific question by index, saving current UI state first.""" + if index == self._current_question_index: + return + if not (0 <= index < len(self.request.questions)): + return + # Save current cursor state (not as an answer — only submit() writes answers) + self._saved_selections[self._current_question_index] = ( + self._selected_index, + set(self._multi_selected), + ) + self._current_question_index = index + self._setup_current_question() + + def next_tab(self) -> None: + """Switch to the next question tab (no wrap).""" + if self._current_question_index < len(self.request.questions) - 1: + self.go_to(self._current_question_index + 1) + + def prev_tab(self) -> None: + """Switch to the previous question tab (no wrap).""" + if self._current_question_index > 0: + self.go_to(self._current_question_index - 1) + def move_up(self) -> None: self._selected_index = (self._selected_index - 1) % len(self._options) @@ -563,6 +628,8 @@ def submit(self) -> bool: if self.is_other_selected: return False # caller should handle Other input self._answers[q.question] = self._options[self._selected_index][0] + # Clear stale draft so returning to this question uses the submitted answer + self._saved_selections.pop(self._current_question_index, None) return self._advance() def submit_other(self, text: str) -> bool: @@ -581,15 +648,24 @@ def submit_other(self, text: str) -> bool: self._answers[q.question] = ", ".join(selected_labels) if selected_labels else text else: self._answers[q.question] = text + # Clear stale draft so returning to this question uses the submitted answer + self._saved_selections.pop(self._current_question_index, None) return self._advance() def _advance(self) -> bool: - """Move to next question. Returns True if all questions are done.""" - self._current_question_index += 1 - if self._current_question_index >= len(self.request.questions): + """Move to the next unanswered question. Returns True if all questions are done.""" + total = len(self.request.questions) + # Check if all questions have been answered + if len(self._answers) >= total: return True - self._setup_current_question() - return False + # Find the next unanswered question (starting from current + 1, wrapping) + for offset in range(1, total + 1): + idx = (self._current_question_index + offset) % total + if self.request.questions[idx].question not in self._answers: + self._current_question_index = idx + self._setup_current_question() + return False + return True def get_answers(self) -> dict[str, str]: return self._answers @@ -697,13 +773,12 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: await listener.resume() return - # Handle ENTER on question panel when "Other" is selected + # Handle ENTER/SPACE on question panel when "Other" is selected panel = self._current_question_panel - if ( - event == KeyEvent.ENTER - and panel is not None - and panel.should_prompt_other_input() - ): + _is_submit_key = event == KeyEvent.ENTER or ( + event == KeyEvent.SPACE and panel is not None and not panel.is_multi_select + ) + if _is_submit_key and panel is not None and panel.should_prompt_other_input(): question_text = panel.current_question_text await listener.pause() live.stop() @@ -716,8 +791,7 @@ async def keyboard_handler(listener: KeyboardListener, event: KeyEvent) -> None: all_done = panel.submit_other(text) if all_done: - answers = panel.get_answers() - panel.request.resolve(answers) + panel.request.resolve(panel.get_answers()) self.show_next_question_request() live.update(self.compose(), refresh=True) return @@ -822,33 +896,42 @@ def dispatch_wire_message(self, msg: WireMessage) -> None: case ToolCallRequest(): logger.warning("Unexpected ToolCallRequest in shell UI: {msg}", msg=msg) + def _try_submit_question(self) -> None: + """Submit the current question answer; if all done, resolve and advance.""" + panel = self._current_question_panel + if panel is None: + return + all_done = panel.submit() + if all_done: + panel.request.resolve(panel.get_answers()) + self.show_next_question_request() + def dispatch_keyboard_event(self, event: KeyEvent) -> None: # Handle question panel keyboard events if self._current_question_panel is not None: match event: case KeyEvent.UP: self._current_question_panel.move_up() - self.refresh_soon() case KeyEvent.DOWN: self._current_question_panel.move_down() - self.refresh_soon() + case KeyEvent.LEFT: + self._current_question_panel.prev_tab() + case KeyEvent.RIGHT | KeyEvent.TAB: + self._current_question_panel.next_tab() case KeyEvent.SPACE: - self._current_question_panel.toggle_select() - self.refresh_soon() + if self._current_question_panel.is_multi_select: + self._current_question_panel.toggle_select() + else: + self._try_submit_question() case KeyEvent.ENTER: # "Other" is handled in keyboard_handler (async context) - all_done = self._current_question_panel.submit() - if all_done: - answers = self._current_question_panel.get_answers() - self._current_question_panel.request.resolve(answers) - self.show_next_question_request() - self.refresh_soon() + self._try_submit_question() case KeyEvent.ESCAPE: self._current_question_panel.request.resolve({}) self.show_next_question_request() - self.refresh_soon() case _: pass + self.refresh_soon() return # handle ESC key to cancel the run diff --git a/tests/ui_and_conv/test_question_panel.py b/tests/ui_and_conv/test_question_panel.py index 599c18273..62cca98a8 100644 --- a/tests/ui_and_conv/test_question_panel.py +++ b/tests/ui_and_conv/test_question_panel.py @@ -2,10 +2,22 @@ from __future__ import annotations +from io import StringIO + +from rich.console import Console + from kimi_cli.ui.shell.visualize import _QuestionRequestPanel from kimi_cli.wire.types import QuestionItem, QuestionOption, QuestionRequest +def _render_to_str(panel: _QuestionRequestPanel) -> str: + """Render the panel to a plain-text string via a Rich Console.""" + buf = StringIO() + console = Console(file=buf, force_terminal=False, width=120) + console.print(panel.render()) + return buf.getvalue() + + def _make_request( questions: list[dict] | None = None, ) -> QuestionRequest: @@ -230,3 +242,427 @@ def test_wrap_around_navigation(): all_done = panel.submit() assert all_done is True assert panel.get_answers() == {"Pick one?": "A"} + + +# --------------------------------------------------------------------------- +# Tab navigation +# --------------------------------------------------------------------------- + + +def _make_multi_question_request() -> QuestionRequest: + """Helper: 3 questions with headers for tab navigation tests.""" + return _make_request( + [ + { + "question": "Q1?", + "header": "Food", + "options": [("Rice", ""), ("Noodle", ""), ("Bread", "")], + }, + { + "question": "Q2?", + "header": "Drink", + "options": [("Tea", ""), ("Coffee", "")], + }, + { + "question": "Q3?", + "header": "Dessert", + "options": [("Cake", ""), ("IceCream", "")], + "multi_select": True, + }, + ] + ) + + +def test_tab_navigation_no_wraparound(): + """prev_tab at first and next_tab at last should be no-ops.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + assert panel._current_question_index == 0 + panel.prev_tab() # already at first — should stay + assert panel._current_question_index == 0 + + panel.go_to(2) + assert panel._current_question_index == 2 + panel.next_tab() # already at last — should stay + assert panel._current_question_index == 2 + + +def test_tab_navigation_preserves_cursor(): + """Switching tabs should save and restore cursor position.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Move cursor to option 2 on Q1 + panel.move_down() # index 1 + panel.move_down() # index 2 + assert panel._selected_index == 2 + + # Switch to Q2 + panel.next_tab() + assert panel._current_question_index == 1 + assert panel._selected_index == 0 # Q2 starts at 0 + + # Move cursor on Q2 + panel.move_down() # index 1 + assert panel._selected_index == 1 + + # Switch back to Q1 — cursor should be restored to 2 + panel.prev_tab() + assert panel._current_question_index == 0 + assert panel._selected_index == 2 + + +def test_tab_navigation_preserves_multi_select(): + """Switching tabs should save and restore multi-select checkbox state.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Go to Q3 (multi-select) + panel.go_to(2) + assert panel.is_multi_select + + # Toggle Cake and IceCream + panel.toggle_select() # Cake + panel.move_down() + panel.toggle_select() # IceCream + assert panel._multi_selected == {0, 1} + + # Switch to Q1 + panel.prev_tab() + assert panel._current_question_index == 1 # goes to Q2 (index 1), not Q1 + panel.prev_tab() + assert panel._current_question_index == 0 + + # Switch back to Q3 — multi-select state should be restored + panel.go_to(2) + assert panel._multi_selected == {0, 1} + + +def test_go_to_same_index_is_noop(): + """go_to(current_index) should not overwrite saved state.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + panel.move_down() + panel.go_to(0) # same index — should be no-op + assert panel._selected_index == 1 # cursor unchanged + + +def test_go_to_out_of_bounds(): + """go_to with invalid index should be no-op.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + panel.go_to(-1) + assert panel._current_question_index == 0 + panel.go_to(99) + assert panel._current_question_index == 0 + + +# --------------------------------------------------------------------------- +# Answer restoration after submission +# --------------------------------------------------------------------------- + + +def test_submit_clears_saved_selections(): + """After submit(), stale draft should be cleared so answer is used for restoration.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Move cursor to Noodle (index 1) on Q1, then switch to Q2 (saves draft) + panel.move_down() # index 1 (Noodle) + panel.next_tab() # saves draft {selected_index: 1} for Q1 + + # Switch back to Q1, change to Bread (index 2), submit + panel.prev_tab() + assert panel._selected_index == 1 # restored from draft + panel.move_down() # index 2 (Bread) + all_done = panel.submit() + assert all_done is False # Q2 and Q3 still pending + + # Verify the draft for Q1 is cleared + assert 0 not in panel._saved_selections + + # Submit Q2 (default) and go to Q3 + all_done = panel.submit() + assert all_done is False + + # Now go back to Q1 — should show Bread (from answer), not Noodle (stale draft) + panel.go_to(0) + assert panel._selected_index == 2 # Bread is at index 2 + + +def test_submit_other_clears_saved_selections(): + """After submit_other(), stale draft should be cleared.""" + request = _make_request( + [ + {"question": "Q1?", "options": [("A", ""), ("B", "")]}, + {"question": "Q2?", "options": [("C", ""), ("D", "")]}, + ] + ) + panel = _QuestionRequestPanel(request) + + # Move to Other on Q1 + panel.move_down() # B + panel.move_down() # Other + assert panel.is_other_selected + + # Switch to Q2 (saves draft with cursor on Other) + panel.next_tab() + + # Switch back, submit with Other text + panel.prev_tab() + assert panel.is_other_selected # restored from draft + assert panel.submit() is False # needs text + panel.submit_other("custom") + + # Draft should be cleared + assert 0 not in panel._saved_selections + + # Go back — should restore from answer, not stale draft + panel.go_to(0) + # "custom" doesn't match A or B, so it should be recognized as Other text + # and cursor should land on the synthetic Other option. + assert panel.is_other_selected + + +# --------------------------------------------------------------------------- +# Multi-select answer restoration from comma-separated string +# --------------------------------------------------------------------------- + + +def test_multi_select_answer_restoration(): + """Returning to a submitted multi-select question should restore checkboxes.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Go to Q3 (multi-select: Cake, IceCream) + panel.go_to(2) + + # Select both options + panel.toggle_select() # Cake (index 0) + panel.move_down() + panel.toggle_select() # IceCream (index 1) + + # Submit Q3 + all_done = panel.submit() + assert all_done is False # Q1 and Q2 still pending + assert panel.get_answers()["Q3?"] == "Cake, IceCream" + + # Submit Q1 (now current after Q3 advance) + panel.submit() # Q1 default (Rice) + + # Submit Q2 + panel.submit() # Q2 default (Tea) + + # Verify all answers + answers = panel.get_answers() + assert answers == {"Q1?": "Rice", "Q2?": "Tea", "Q3?": "Cake, IceCream"} + + +def test_multi_select_answer_restoration_after_revisit(): + """Revisiting a submitted multi-select question should show correct checkboxes.""" + request = _make_request( + [ + {"question": "Q1?", "options": [("A", "")], "multi_select": True}, + {"question": "Q2?", "options": [("B", "")]}, + ] + ) + panel = _QuestionRequestPanel(request) + + # Select A and submit Q1 + panel.toggle_select() # A + panel.submit() # → advances to Q2 + + # Go back to Q1 + panel.go_to(0) + + # _multi_selected should contain {0} (A was checked) + assert 0 in panel._multi_selected + assert panel._selected_index == 0 + + +def test_multi_select_answer_restoration_with_other(): + """Restoring multi-select with Other text should mark Other as selected.""" + request = _make_request( + [ + { + "question": "Pick?", + "options": [("X", ""), ("Y", "")], + "multi_select": True, + }, + {"question": "Q2?", "options": [("Z", "")]}, + ] + ) + panel = _QuestionRequestPanel(request) + + # Select X and Other + panel.toggle_select() # X (index 0) + panel.move_down() # Y (index 1) + panel.move_down() # Other (index 2) + panel.toggle_select() # Other + + # submit() returns False (Other needs text) + assert panel.submit() is False + panel.submit_other("custom") + + assert panel.get_answers()["Pick?"] == "X, custom" + + # Go back to Q1 + panel.go_to(0) + + # X should be in _multi_selected, and Other (index 2) too + assert 0 in panel._multi_selected # X + assert 2 in panel._multi_selected # Other (because "custom" didn't match any known label) + + +def test_single_select_answer_restoration(): + """Revisiting a submitted single-select question should restore cursor.""" + request = _make_request( + [ + {"question": "Q1?", "options": [("A", ""), ("B", ""), ("C", "")]}, + {"question": "Q2?", "options": [("D", ""), ("E", "")]}, + ] + ) + panel = _QuestionRequestPanel(request) + + # Select B (index 1) and submit Q1 + panel.move_down() + panel.submit() + + # Go back to Q1 + panel.go_to(0) + assert panel._selected_index == 1 # B + + +# --------------------------------------------------------------------------- +# Multi-question advance logic +# --------------------------------------------------------------------------- + + +def test_advance_skips_answered_questions(): + """After submitting Q1, advance should go to Q2, not Q3 if Q2 is unanswered.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Submit Q1 default (Rice) → should advance to Q2 + panel.submit() + assert panel._current_question_index == 1 + assert panel.current_question_text == "Q2?" + + # Submit Q2 default (Tea) → should advance to Q3 + panel.submit() + assert panel._current_question_index == 2 + assert panel.current_question_text == "Q3?" + + +def test_advance_finds_first_unanswered(): + """After answering Q1 and Q3, submitting should cycle back to Q2.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Answer Q1 + panel.submit() # Rice → advance to Q2 + assert panel._current_question_index == 1 + + # Skip Q2 by tabbing to Q3 + panel.next_tab() + assert panel._current_question_index == 2 + + # Answer Q3 (multi-select: Cake) + panel.toggle_select() + panel.submit() + + # Should advance to Q2 (the only unanswered) + assert panel._current_question_index == 1 + assert panel.current_question_text == "Q2?" + + +# --------------------------------------------------------------------------- +# Render validation +# --------------------------------------------------------------------------- + + +def test_render_does_not_crash(): + """render() should not raise for any state.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + # Render initial state + _render_to_str(panel) + + # Render after some navigation + panel.move_down() + _render_to_str(panel) + + # Render on multi-select question + panel.go_to(2) + panel.toggle_select() + _render_to_str(panel) + + # Render after submission + panel.go_to(0) + panel.submit() + _render_to_str(panel) + + +def test_render_tab_bar_status(): + """Tab bar should show correct ●/✓/○ status indicators.""" + panel = _QuestionRequestPanel(_make_multi_question_request()) + + rendered = _render_to_str(panel) + + # Q1 is active (●), Q2 and Q3 are unanswered (○) + assert "\u25cf" in rendered # ● + assert "\u25cb" in rendered # ○ + + # Submit Q1, check Q1 shows ✓ + panel.submit() + rendered = _render_to_str(panel) + assert "\u2713" in rendered # ✓ + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +def test_single_question_no_tab_bar(): + """Single-question request should not render tab bar.""" + request = _make_request() + panel = _QuestionRequestPanel(request) + rendered = _render_to_str(panel) + + # No tab indicators since there's only one question + assert "\u25cf" not in rendered + + +def test_header_fallback(): + """Questions without headers should use Q1, Q2, etc.""" + request = _make_request( + [ + {"question": "First?", "options": [("A", "")]}, + {"question": "Second?", "options": [("B", "")]}, + ] + ) + panel = _QuestionRequestPanel(request) + rendered = _render_to_str(panel) + assert "Q1" in rendered + assert "Q2" in rendered + + +def test_submit_all_questions_returns_true(): + """Submitting the last unanswered question should return True.""" + request = _make_request( + [ + {"question": "Q1?", "options": [("A", "")]}, + {"question": "Q2?", "options": [("B", "")]}, + ] + ) + panel = _QuestionRequestPanel(request) + + assert panel.submit() is False # Q2 still pending + assert panel.submit() is True # all done + assert panel.get_answers() == {"Q1?": "A", "Q2?": "B"} + + +def test_toggle_select_noop_in_single_select(): + """toggle_select should do nothing in single-select mode.""" + request = _make_request() + panel = _QuestionRequestPanel(request) + + panel.toggle_select() # should be no-op + assert panel._multi_selected == set() + + all_done = panel.submit() + assert all_done is True + assert panel.get_answers() == {"Pick one?": "A"} diff --git a/web/src/features/chat/components/question-dialog.tsx b/web/src/features/chat/components/question-dialog.tsx index 8be75c8a8..c9aad20d9 100644 --- a/web/src/features/chat/components/question-dialog.tsx +++ b/web/src/features/chat/components/question-dialog.tsx @@ -52,11 +52,14 @@ export function QuestionDialog({ const totalQuestions = questions.length; const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); - const [selectedIndex, setSelectedIndex] = useState(null); + const [selectedIndex, setSelectedIndex] = useState(0); const [multiSelected, setMultiSelected] = useState>(new Set()); const [otherText, setOtherText] = useState(""); const [answers, setAnswers] = useState>({}); const otherInputRef = useRef(null); + const savedSelectionsRef = useRef< + Map; otherText: string }> + >(new Map()); // Reset state when the pending question changes const questionId = pendingQuestion?.question.id; @@ -65,10 +68,11 @@ export function QuestionDialog({ if (questionId !== prevQuestionIdRef.current) { prevQuestionIdRef.current = questionId; setCurrentQuestionIndex(0); - setSelectedIndex(null); + setSelectedIndex(0); setMultiSelected(new Set()); setOtherText(""); setAnswers({}); + savedSelectionsRef.current.clear(); } }, [questionId]); @@ -78,11 +82,35 @@ export function QuestionDialog({ const isMultiSelect = currentQuestion?.multi_select ?? false; const otherIndex = options.length; + // Auto-focus/blur Other input as selectedIndex moves in/out + useEffect(() => { + if (selectedIndex === otherIndex) { + // In multi-select, only auto-focus if Other is checked + if (!isMultiSelect || multiSelected.has(otherIndex)) { + otherInputRef.current?.focus(); + } + } else if (document.activeElement === otherInputRef.current) { + otherInputRef.current?.blur(); + } + }, [selectedIndex, otherIndex, isMultiSelect, multiSelected]); + const questionPending = questionId ? pendingQuestionMap[questionId] === true : false; const disableActions = !onQuestionResponse || questionPending; + const focusOtherInputAfterToggle = useCallback((wasSelectedBeforeToggle: boolean) => { + if (wasSelectedBeforeToggle) { + // Unchecking Other: blur to prevent onFocus re-adding + if (document.activeElement === otherInputRef.current) { + otherInputRef.current?.blur(); + } + } else { + // Checking Other: focus input for typing + setTimeout(() => otherInputRef.current?.focus(), 0); + } + }, []); + const getCurrentAnswer = useCallback((): string | null => { if (isMultiSelect) { const labels: string[] = []; @@ -95,47 +123,124 @@ export function QuestionDialog({ } return labels.length > 0 ? labels.join(", ") : null; } - if (selectedIndex === null) return null; if (selectedIndex === otherIndex) { return otherText.trim() || null; } return options[selectedIndex]?.label ?? null; }, [isMultiSelect, multiSelected, selectedIndex, otherIndex, otherText, options]); - const handleSubmitCurrent = useCallback(async () => { - if (disableActions || !currentQuestion || !pendingQuestion) return; + /** Restore selection state for a given question index. */ + const restoreForQuestion = useCallback( + (index: number) => { + const saved = savedSelectionsRef.current.get(index); + if (saved) { + setSelectedIndex(saved.selectedIndex); + setMultiSelected(saved.multiSelected); + setOtherText(saved.otherText); + return; + } + // Fall back to answers dict — find which option was submitted + const targetQuestion = questions[index]; + if (targetQuestion) { + const prevAnswer = answers[targetQuestion.question]; + if (prevAnswer) { + if (targetQuestion.multi_select) { + const answerLabels = prevAnswer.split(", ").map((s) => s.trim()); + const knownLabels = new Set(targetQuestion.options.map((o) => o.label)); + const selected = new Set(); + targetQuestion.options.forEach((o, i) => { + if (answerLabels.includes(o.label)) { + selected.add(i); + } + }); + // Unmatched labels = Other text + const otherParts = answerLabels.filter((l) => !knownLabels.has(l)); + if (otherParts.length > 0) { + selected.add(targetQuestion.options.length); + setOtherText(otherParts.join(", ")); + } else { + setOtherText(""); + } + setMultiSelected(selected); + setSelectedIndex(selected.size > 0 ? Math.min(...selected) : 0); + } else { + const foundIdx = targetQuestion.options.findIndex( + (o) => o.label === prevAnswer, + ); + if (foundIdx >= 0) { + setSelectedIndex(foundIdx); + setOtherText(""); + } else { + // Answer was Other text + setSelectedIndex(targetQuestion.options.length); + setOtherText(prevAnswer); + } + setMultiSelected(new Set()); + } + return; + } + } + // Brand new question — default to first option + setSelectedIndex(0); + setMultiSelected(new Set()); + setOtherText(""); + }, + [questions, answers], + ); - const answer = getCurrentAnswer(); - if (!answer) return; + /** Core advance logic: save state, record answer, advance or submit all. */ + const advanceWithAnswer = useCallback( + async (answer: string) => { + if (disableActions || !currentQuestion || !pendingQuestion) return; - const newAnswers = { - ...answers, - [currentQuestion.question]: answer, - }; + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } - if (currentQuestionIndex < totalQuestions - 1) { + const newAnswers = { + ...answers, + [currentQuestion.question]: answer, + }; + savedSelectionsRef.current.delete(currentQuestionIndex); + // Keep local state in sync immediately, even if the final async submit fails. setAnswers(newAnswers); - setCurrentQuestionIndex((prev) => prev + 1); - setSelectedIndex(null); - setMultiSelected(new Set()); - setOtherText(""); - } else { - try { - await onQuestionResponse!(pendingQuestion.question.id, newAnswers); - } catch (error) { - console.error("[QuestionDialog] Failed to respond", error); + + const allAnswered = questions.every((q) => q.question in newAnswers); + if (allAnswered) { + try { + await onQuestionResponse!(pendingQuestion.question.id, newAnswers); + } catch (error) { + console.error("[QuestionDialog] Failed to respond", error); + } + } else { + for (let offset = 1; offset <= totalQuestions; offset++) { + const idx = (currentQuestionIndex + offset) % totalQuestions; + if (!(questions[idx].question in newAnswers)) { + setCurrentQuestionIndex(idx); + restoreForQuestion(idx); + break; + } + } } - } - }, [ - disableActions, - currentQuestion, - pendingQuestion, - getCurrentAnswer, - answers, - currentQuestionIndex, - totalQuestions, - onQuestionResponse, - ]); + }, + [ + disableActions, + currentQuestion, + pendingQuestion, + answers, + questions, + currentQuestionIndex, + totalQuestions, + onQuestionResponse, + restoreForQuestion, + ], + ); + + const handleSubmitCurrent = useCallback(async () => { + const answer = getCurrentAnswer(); + if (!answer) return; + await advanceWithAnswer(answer); + }, [getCurrentAnswer, advanceWithAnswer]); const handleDismiss = useCallback(async () => { if (disableActions || !pendingQuestion) return; @@ -146,11 +251,28 @@ export function QuestionDialog({ } }, [disableActions, pendingQuestion, onQuestionResponse]); + const handleTabClick = useCallback( + (index: number) => { + if (disableActions || index === currentQuestionIndex) return; + // Save current cursor state + savedSelectionsRef.current.set(currentQuestionIndex, { + selectedIndex, + multiSelected, + otherText, + }); + setCurrentQuestionIndex(index); + restoreForQuestion(index); + }, + [disableActions, currentQuestionIndex, selectedIndex, multiSelected, otherText, restoreForQuestion], + ); + const handleOptionClick = useCallback( (idx: number) => { if (disableActions) return; if (isMultiSelect) { + const wasSelectedBeforeToggle = multiSelected.has(idx); + setSelectedIndex(idx); setMultiSelected((prev) => { const next = new Set(prev); if (next.has(idx)) { @@ -161,16 +283,22 @@ export function QuestionDialog({ return next; }); if (idx === otherIndex) { - setTimeout(() => otherInputRef.current?.focus(), 0); + focusOtherInputAfterToggle(wasSelectedBeforeToggle); } + } else if (idx === otherIndex) { + // Other option: focus input for typing + setSelectedIndex(idx); + setTimeout(() => otherInputRef.current?.focus(), 0); } else { + // Single-select click on regular option: auto-confirm setSelectedIndex(idx); - if (idx === otherIndex) { - setTimeout(() => otherInputRef.current?.focus(), 0); + const clickedAnswer = options[idx]?.label; + if (clickedAnswer) { + advanceWithAnswer(clickedAnswer); } } }, - [disableActions, isMultiSelect, otherIndex], + [disableActions, isMultiSelect, otherIndex, options, advanceWithAnswer, focusOtherInputAfterToggle, multiSelected], ); // Keyboard navigation @@ -197,19 +325,38 @@ export function QuestionDialog({ return; } - if (event.key === "ArrowDown" || event.key === "j") { + if (event.key === "ArrowLeft" && currentQuestionIndex > 0) { event.preventDefault(); - if (!isMultiSelect) { - setSelectedIndex((prev) => - prev === null ? 0 : Math.min(prev + 1, totalOptions - 1), - ); - } + handleTabClick(currentQuestionIndex - 1); + } else if (event.key === "ArrowRight" && currentQuestionIndex < totalQuestions - 1) { + event.preventDefault(); + handleTabClick(currentQuestionIndex + 1); + } else if (event.key === "ArrowDown" || event.key === "j") { + event.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, totalOptions - 1)); } else if (event.key === "ArrowUp" || event.key === "k") { event.preventDefault(); - if (!isMultiSelect) { - setSelectedIndex((prev) => - prev === null ? totalOptions - 1 : Math.max(prev - 1, 0), - ); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + } else if (event.key === " ") { + event.preventDefault(); + if (isMultiSelect) { + const wasSelectedBeforeToggle = multiSelected.has(selectedIndex); + // Toggle checkbox at focused position + setMultiSelected((prev) => { + const next = new Set(prev); + if (next.has(selectedIndex)) { + next.delete(selectedIndex); + } else { + next.add(selectedIndex); + } + return next; + }); + if (selectedIndex === otherIndex) { + focusOtherInputAfterToggle(wasSelectedBeforeToggle); + } + } else { + // Single-select: confirm like Enter + handleSubmitCurrent(); } } else if (event.key === "Enter") { event.preventDefault(); @@ -222,12 +369,17 @@ export function QuestionDialog({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [pendingQuestion, disableActions, options.length, isMultiSelect, handleSubmitCurrent, handleOptionClick, handleDismiss]); + }, [pendingQuestion, disableActions, options.length, isMultiSelect, selectedIndex, otherIndex, handleSubmitCurrent, handleOptionClick, handleDismiss, handleTabClick, currentQuestionIndex, totalQuestions, multiSelected, focusOtherInputAfterToggle]); if (!(pendingQuestion && currentQuestion)) return null; const hasAnswer = getCurrentAnswer() !== null; - const isLastQuestion = currentQuestionIndex >= totalQuestions - 1; + // Would submitting the current answer complete all questions? + const allQuestionsAnswered = + hasAnswer && + questions.every( + (q) => q.question in answers || q.question === currentQuestion.question, + ); return (
@@ -240,12 +392,43 @@ export function QuestionDialog({ "overflow-hidden", )} > - {/* Header */} -
+ {/* Tab bar for multi-question */} + {totalQuestions > 1 && ( +
+ {questions.map((q, i) => { + const label = q.header || `Q${i + 1}`; + const isAnswered = q.question in answers; + const isActive = i === currentQuestionIndex; + return ( + + ); + })} +
+ )} + + {/* Question text */} +
{currentQuestion.question} - {currentQuestion.header && ( + {totalQuestions === 1 && currentQuestion.header && ( {currentQuestion.header} @@ -255,9 +438,8 @@ export function QuestionDialog({ {/* Options */}
{options.map((option, idx) => { - const isSelected = isMultiSelect - ? multiSelected.has(idx) - : selectedIndex === idx; + const isFocused = selectedIndex === idx; + const isChecked = isMultiSelect && multiSelected.has(idx); const displayNumber = idx + 1; return ( @@ -269,13 +451,15 @@ export function QuestionDialog({ className={cn( "flex items-start gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors cursor-pointer", "hover:bg-muted/50", - isSelected && "bg-muted/70", + isChecked && "bg-primary/[0.08]", + isFocused && !isChecked && "bg-muted/70", + isFocused && isChecked && "ring-1 ring-inset ring-primary/20", disableActions && "opacity-50 cursor-not-allowed", )} > {isMultiSelect ? ( @@ -302,11 +486,14 @@ export function QuestionDialog({
{isMultiSelect ? ( handleOptionClick(otherIndex)} className="pointer-events-auto" tabIndex={-1} @@ -328,9 +515,8 @@ export function QuestionDialog({ } }} onFocus={() => { - if (!isMultiSelect) { - setSelectedIndex(otherIndex); - } else if (!multiSelected.has(otherIndex)) { + setSelectedIndex(otherIndex); + if (isMultiSelect && !multiSelected.has(otherIndex)) { setMultiSelected((prev) => new Set(prev).add(otherIndex)); } }} @@ -341,6 +527,12 @@ export function QuestionDialog({ } else if (e.key === "Escape") { e.preventDefault(); (e.target as HTMLInputElement).blur(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex(Math.max(otherIndex - 1, 0)); + } else if (e.key === "ArrowDown") { + // Already at last position — no-op but prevent default + e.preventDefault(); } }} placeholder="Type your answer..." @@ -356,12 +548,38 @@ export function QuestionDialog({
{/* Footer */} -
- {totalQuestions > 1 && ( - - {currentQuestionIndex + 1} / {totalQuestions} +
+ {/* Keyboard hints */} +
+ + ↑↓ + select - )} + {totalQuestions > 1 && ( + + ←→ + switch + + )} + {isMultiSelect ? ( + <> + + space + toggle + + + + confirm + + + ) : ( + + space/↵ + confirm + + )} +
+