Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

## 未发布

- Shell:为多问题面板添加标签式导航——使用左右方向键或 Tab 键在问题间切换,并以可视化指示器区分已答、当前和待答状态,重新访问已答问题时自动恢复选择状态
- Shell:在问题面板中支持使用空格键提交单选问题
- Web:为多问题对话框添加标签式导航,支持可点击标签栏、键盘导航,以及重新访问已答问题时恢复选择状态
- Core:将进程标题设置为 "Kimi Code"(在 `ps` / 活动监视器 / 终端标签页标题中可见),并将 Web Worker 子进程标记为 "kimi-code-worker"

## 1.14.0 (2026-02-26)
Expand Down
152 changes: 117 additions & 35 deletions src/kimi_cli/ui/shell/visualize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -459,8 +460,35 @@ 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:
self._selected_index = 0
Copy link

Copilot AI Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When restoring a single-select answer that doesn't match any known option label, the code defaults to index 0 (first option). This is inconsistent with the web UI implementation (question-dialog.tsx lines 162-164) which correctly restores to the "Other" option when the answer doesn't match any label. The Python code should set self._selected_index = len(self._options) - 1 (the Other option) instead of defaulting to 0. Note that the "Other" text restoration is also missing - there's no equivalent of the web's setOtherText(prevAnswer). Without this, users lose their custom "Other" text when navigating back to a submitted question.

Suggested change
self._selected_index = 0
# If the restored answer doesn't match any known label,
# default to the "Other" option (last in the list).
self._selected_index = len(self._options) - 1

Copilot uses AI. Check for mistakes.
self._multi_selected = set()
else:
self._selected_index = 0
self._multi_selected = set()

@property
def _current_question(self):
Expand Down Expand Up @@ -489,10 +517,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"))
Expand All @@ -501,8 +541,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:
Expand All @@ -517,19 +557,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)

Expand Down Expand Up @@ -563,6 +627,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:
Expand All @@ -581,15 +647,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
Expand Down Expand Up @@ -697,13 +772,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()
Expand All @@ -716,8 +790,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
Expand Down Expand Up @@ -822,33 +895,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
Expand Down
Loading
Loading