Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
153 changes: 118 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,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):
Expand Down Expand Up @@ -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"))
Expand All @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading