comate-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- comate_cli/__init__.py +5 -0
- comate_cli/__main__.py +5 -0
- comate_cli/main.py +128 -0
- comate_cli/terminal_agent/__init__.py +2 -0
- comate_cli/terminal_agent/animations.py +283 -0
- comate_cli/terminal_agent/app.py +261 -0
- comate_cli/terminal_agent/assistant_render.py +243 -0
- comate_cli/terminal_agent/env_utils.py +37 -0
- comate_cli/terminal_agent/error_display.py +46 -0
- comate_cli/terminal_agent/event_renderer.py +867 -0
- comate_cli/terminal_agent/fragment_utils.py +25 -0
- comate_cli/terminal_agent/history_printer.py +150 -0
- comate_cli/terminal_agent/input_geometry.py +92 -0
- comate_cli/terminal_agent/layout_coordinator.py +188 -0
- comate_cli/terminal_agent/logging_adapter.py +147 -0
- comate_cli/terminal_agent/logo.py +58 -0
- comate_cli/terminal_agent/markdown_render.py +24 -0
- comate_cli/terminal_agent/mention_completer.py +293 -0
- comate_cli/terminal_agent/message_style.py +33 -0
- comate_cli/terminal_agent/models.py +89 -0
- comate_cli/terminal_agent/question_view.py +584 -0
- comate_cli/terminal_agent/rewind_store.py +712 -0
- comate_cli/terminal_agent/rpc_protocol.py +103 -0
- comate_cli/terminal_agent/rpc_stdio.py +280 -0
- comate_cli/terminal_agent/selection_menu.py +305 -0
- comate_cli/terminal_agent/session_view.py +99 -0
- comate_cli/terminal_agent/slash_commands.py +142 -0
- comate_cli/terminal_agent/startup.py +77 -0
- comate_cli/terminal_agent/status_bar.py +258 -0
- comate_cli/terminal_agent/text_effects.py +30 -0
- comate_cli/terminal_agent/tool_view.py +584 -0
- comate_cli/terminal_agent/tui.py +1006 -0
- comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
- comate_cli/terminal_agent/tui_parts/commands.py +759 -0
- comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
- comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
- comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
- comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
- comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
- comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
- comate_cli-0.1.0.dist-info/METADATA +37 -0
- comate_cli-0.1.0.dist-info/RECORD +44 -0
- comate_cli-0.1.0.dist-info/WHEEL +4 -0
- comate_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.document import Document
|
|
7
|
+
from prompt_toolkit.filters import Condition
|
|
8
|
+
from prompt_toolkit.layout import HSplit, Window
|
|
9
|
+
from prompt_toolkit.layout.containers import ConditionalContainer
|
|
10
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
11
|
+
from prompt_toolkit.widgets import TextArea
|
|
12
|
+
|
|
13
|
+
_CANCEL_MESSAGE = "user reject answer this question."
|
|
14
|
+
_CHAT_MESSAGE = "Chat about this"
|
|
15
|
+
_PREVIEW_CUSTOM_INPUT_MAX_CHARS = 15
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class OptionState:
|
|
20
|
+
"""单个候选项状态。"""
|
|
21
|
+
|
|
22
|
+
label: str
|
|
23
|
+
description: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class QuestionState:
|
|
28
|
+
"""单个问题状态。"""
|
|
29
|
+
|
|
30
|
+
question: str
|
|
31
|
+
header: str
|
|
32
|
+
options: list[OptionState]
|
|
33
|
+
multi_select: bool
|
|
34
|
+
selected_indices: set[int] = field(default_factory=set)
|
|
35
|
+
custom_input: str = ""
|
|
36
|
+
preset_answer: str = ""
|
|
37
|
+
is_answered: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class QuestionUIState:
|
|
42
|
+
"""问答 UI 整体状态。"""
|
|
43
|
+
|
|
44
|
+
questions: list[QuestionState] = field(default_factory=list)
|
|
45
|
+
current_question_index: int = 0
|
|
46
|
+
current_option_index: int = 0
|
|
47
|
+
is_custom_input_active: bool = False
|
|
48
|
+
is_preview_mode: bool = False
|
|
49
|
+
preview_option_index: int = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class QuestionAction:
|
|
54
|
+
"""问答交互动作输出。"""
|
|
55
|
+
|
|
56
|
+
kind: str
|
|
57
|
+
message: str = ""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class AskUserQuestionUI:
|
|
61
|
+
"""AskUserQuestion 交互式 UI 组件。"""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._state = QuestionUIState()
|
|
65
|
+
|
|
66
|
+
self._question_tabs_control = FormattedTextControl(text=self._tabs_fragments)
|
|
67
|
+
self._question_content_control = FormattedTextControl(text=self._question_content_fragments)
|
|
68
|
+
self._options_control = FormattedTextControl(text=self._options_fragments, focusable=True)
|
|
69
|
+
self._special_options_control = FormattedTextControl(text=self._special_options_fragments)
|
|
70
|
+
self._preview_control = FormattedTextControl(text=self._preview_fragments)
|
|
71
|
+
|
|
72
|
+
self._custom_input_area = TextArea(
|
|
73
|
+
text="",
|
|
74
|
+
multiline=False,
|
|
75
|
+
prompt=" > ",
|
|
76
|
+
wrap_lines=False,
|
|
77
|
+
style="class:question.custom_input",
|
|
78
|
+
)
|
|
79
|
+
self._custom_input_area.window.style = "class:question.custom_input"
|
|
80
|
+
|
|
81
|
+
@self._custom_input_area.buffer.on_text_changed.add_handler
|
|
82
|
+
def _sync_custom_input(buffer) -> None:
|
|
83
|
+
question = self._active_question()
|
|
84
|
+
if question is None:
|
|
85
|
+
return
|
|
86
|
+
question.custom_input = buffer.text
|
|
87
|
+
if not question.multi_select and question.custom_input.strip():
|
|
88
|
+
question.selected_indices.clear()
|
|
89
|
+
question.is_answered = self._question_is_answered(question)
|
|
90
|
+
|
|
91
|
+
self._custom_input_area_container = ConditionalContainer(
|
|
92
|
+
content=HSplit(
|
|
93
|
+
[
|
|
94
|
+
Window(
|
|
95
|
+
content=FormattedTextControl(
|
|
96
|
+
text=[
|
|
97
|
+
(
|
|
98
|
+
"class:question.custom_input.border",
|
|
99
|
+
" ┌──────────────────────────────────────────────┐",
|
|
100
|
+
)
|
|
101
|
+
]
|
|
102
|
+
),
|
|
103
|
+
dont_extend_height=True,
|
|
104
|
+
height=1,
|
|
105
|
+
),
|
|
106
|
+
self._custom_input_area,
|
|
107
|
+
Window(
|
|
108
|
+
content=FormattedTextControl(
|
|
109
|
+
text=[
|
|
110
|
+
(
|
|
111
|
+
"class:question.custom_input.border",
|
|
112
|
+
" └──────────────────────────────────────────────┘",
|
|
113
|
+
)
|
|
114
|
+
]
|
|
115
|
+
),
|
|
116
|
+
dont_extend_height=True,
|
|
117
|
+
height=1,
|
|
118
|
+
),
|
|
119
|
+
]
|
|
120
|
+
),
|
|
121
|
+
filter=Condition(self._show_custom_input),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
self._question_content_window = Window(
|
|
125
|
+
content=self._question_content_control,
|
|
126
|
+
wrap_lines=True,
|
|
127
|
+
dont_extend_height=False,
|
|
128
|
+
style="class:question.body",
|
|
129
|
+
)
|
|
130
|
+
self._options_window = Window(
|
|
131
|
+
content=self._options_control,
|
|
132
|
+
wrap_lines=True,
|
|
133
|
+
dont_extend_height=False,
|
|
134
|
+
style="class:question.body",
|
|
135
|
+
)
|
|
136
|
+
self._special_options_window = Window(
|
|
137
|
+
content=self._special_options_control,
|
|
138
|
+
wrap_lines=True,
|
|
139
|
+
dont_extend_height=False,
|
|
140
|
+
style="class:question.body",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
self._question_mode_container = HSplit(
|
|
144
|
+
[
|
|
145
|
+
self._question_content_window,
|
|
146
|
+
self._options_window,
|
|
147
|
+
self._custom_input_area_container,
|
|
148
|
+
self._special_options_window,
|
|
149
|
+
]
|
|
150
|
+
)
|
|
151
|
+
self._preview_window = Window(
|
|
152
|
+
content=self._preview_control,
|
|
153
|
+
wrap_lines=True,
|
|
154
|
+
dont_extend_height=False,
|
|
155
|
+
style="class:question.body",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
self._root = HSplit(
|
|
159
|
+
[
|
|
160
|
+
Window(
|
|
161
|
+
content=self._question_tabs_control,
|
|
162
|
+
height=1,
|
|
163
|
+
dont_extend_height=True,
|
|
164
|
+
style="class:question.tabs",
|
|
165
|
+
),
|
|
166
|
+
Window(height=1, char="─", style="class:question.divider"),
|
|
167
|
+
ConditionalContainer(
|
|
168
|
+
content=self._question_mode_container,
|
|
169
|
+
filter=Condition(lambda: not self._state.is_preview_mode),
|
|
170
|
+
),
|
|
171
|
+
ConditionalContainer(
|
|
172
|
+
content=self._preview_window,
|
|
173
|
+
filter=Condition(lambda: self._state.is_preview_mode),
|
|
174
|
+
),
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def container(self) -> HSplit:
|
|
180
|
+
return self._root
|
|
181
|
+
|
|
182
|
+
def has_questions(self) -> bool:
|
|
183
|
+
return bool(self._state.questions)
|
|
184
|
+
|
|
185
|
+
def set_questions(self, questions: list[dict[str, Any]] | None) -> bool:
|
|
186
|
+
parsed_questions: list[QuestionState] = []
|
|
187
|
+
for idx, raw in enumerate(questions or [], start=1):
|
|
188
|
+
if not isinstance(raw, dict):
|
|
189
|
+
continue
|
|
190
|
+
question_text = str(raw.get("question", "")).strip()
|
|
191
|
+
header = str(raw.get("header", f"Q{idx}")).strip()[:12] or f"Q{idx}"
|
|
192
|
+
raw_options = raw.get("options", [])
|
|
193
|
+
options: list[OptionState] = []
|
|
194
|
+
if isinstance(raw_options, list):
|
|
195
|
+
for option in raw_options:
|
|
196
|
+
if not isinstance(option, dict):
|
|
197
|
+
continue
|
|
198
|
+
label = str(option.get("label", "")).strip()
|
|
199
|
+
if not label:
|
|
200
|
+
continue
|
|
201
|
+
description = str(option.get("description", "")).strip()
|
|
202
|
+
options.append(OptionState(label=label, description=description))
|
|
203
|
+
if not options:
|
|
204
|
+
options.append(OptionState(label="Continue", description="Proceed with default decision."))
|
|
205
|
+
parsed_questions.append(
|
|
206
|
+
QuestionState(
|
|
207
|
+
question=question_text,
|
|
208
|
+
header=header,
|
|
209
|
+
options=options,
|
|
210
|
+
multi_select=bool(raw.get("multiSelect", False)),
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
self._state.questions = parsed_questions
|
|
215
|
+
self._state.current_question_index = 0
|
|
216
|
+
self._state.current_option_index = 0
|
|
217
|
+
self._state.is_custom_input_active = False
|
|
218
|
+
self._state.is_preview_mode = False
|
|
219
|
+
self._state.preview_option_index = 0
|
|
220
|
+
self._sync_custom_input_buffer()
|
|
221
|
+
return bool(self._state.questions)
|
|
222
|
+
|
|
223
|
+
def clear(self) -> None:
|
|
224
|
+
self._state = QuestionUIState()
|
|
225
|
+
self._sync_custom_input_buffer()
|
|
226
|
+
|
|
227
|
+
def focus_target(self) -> Any:
|
|
228
|
+
if self._state.is_custom_input_active:
|
|
229
|
+
return self._custom_input_area.window
|
|
230
|
+
return self._options_window
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def custom_input_window(self) -> Window:
|
|
234
|
+
return self._custom_input_area.window
|
|
235
|
+
|
|
236
|
+
def move_option(self, delta: int) -> None:
|
|
237
|
+
if not self._state.questions:
|
|
238
|
+
return
|
|
239
|
+
if self._state.is_preview_mode:
|
|
240
|
+
self._state.preview_option_index = (self._state.preview_option_index + delta) % 2
|
|
241
|
+
return
|
|
242
|
+
|
|
243
|
+
question = self._active_question()
|
|
244
|
+
if question is None:
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
max_index = len(question.options) + 1
|
|
248
|
+
next_index = self._state.current_option_index + delta
|
|
249
|
+
self._state.current_option_index = max(0, min(max_index, next_index))
|
|
250
|
+
if self._state.current_option_index != len(question.options):
|
|
251
|
+
self._deactivate_custom_input()
|
|
252
|
+
|
|
253
|
+
def prev_question(self) -> None:
|
|
254
|
+
self._switch_question(-1)
|
|
255
|
+
|
|
256
|
+
def next_question(self) -> None:
|
|
257
|
+
self._switch_question(1)
|
|
258
|
+
|
|
259
|
+
def focus_submit(self) -> None:
|
|
260
|
+
if not self._state.questions:
|
|
261
|
+
return
|
|
262
|
+
self._state.is_preview_mode = True
|
|
263
|
+
self._state.preview_option_index = 0
|
|
264
|
+
self._deactivate_custom_input()
|
|
265
|
+
|
|
266
|
+
def toggle_current_selection(self) -> None:
|
|
267
|
+
if not self._state.questions or self._state.is_preview_mode:
|
|
268
|
+
return
|
|
269
|
+
|
|
270
|
+
question = self._active_question()
|
|
271
|
+
if question is None:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
index = self._state.current_option_index
|
|
275
|
+
if index >= len(question.options):
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
if question.multi_select:
|
|
279
|
+
if index in question.selected_indices:
|
|
280
|
+
question.selected_indices.remove(index)
|
|
281
|
+
else:
|
|
282
|
+
question.selected_indices.add(index)
|
|
283
|
+
question.preset_answer = ""
|
|
284
|
+
else:
|
|
285
|
+
question.selected_indices = {index}
|
|
286
|
+
question.custom_input = ""
|
|
287
|
+
question.preset_answer = ""
|
|
288
|
+
if self._state.current_question_index >= 0:
|
|
289
|
+
self._sync_custom_input_buffer()
|
|
290
|
+
question.is_answered = self._question_is_answered(question)
|
|
291
|
+
|
|
292
|
+
def set_custom_input(self, text: str) -> None:
|
|
293
|
+
question = self._active_question()
|
|
294
|
+
if question is None:
|
|
295
|
+
return
|
|
296
|
+
question.custom_input = text
|
|
297
|
+
if not question.multi_select and question.custom_input.strip():
|
|
298
|
+
question.selected_indices.clear()
|
|
299
|
+
question.preset_answer = ""
|
|
300
|
+
question.is_answered = self._question_is_answered(question)
|
|
301
|
+
if self._custom_input_area.text != text:
|
|
302
|
+
self._custom_input_area.buffer.document = Document(text=text, cursor_position=len(text))
|
|
303
|
+
|
|
304
|
+
def handle_enter(self) -> QuestionAction | None:
|
|
305
|
+
if not self._state.questions:
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
if self._state.is_preview_mode:
|
|
309
|
+
if self._state.preview_option_index == 0:
|
|
310
|
+
return QuestionAction(kind="submit", message=self.build_answers_message())
|
|
311
|
+
self._state.is_preview_mode = False
|
|
312
|
+
self._state.preview_option_index = 0
|
|
313
|
+
self._deactivate_custom_input()
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
question = self._active_question()
|
|
317
|
+
if question is None:
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
current_index = self._state.current_option_index
|
|
321
|
+
custom_index = len(question.options)
|
|
322
|
+
chat_index = len(question.options) + 1
|
|
323
|
+
|
|
324
|
+
if current_index == custom_index:
|
|
325
|
+
if not self._state.is_custom_input_active:
|
|
326
|
+
self._state.is_custom_input_active = True
|
|
327
|
+
if not question.multi_select:
|
|
328
|
+
question.selected_indices.clear()
|
|
329
|
+
question.preset_answer = ""
|
|
330
|
+
self._sync_custom_input_buffer()
|
|
331
|
+
return None
|
|
332
|
+
if not question.custom_input.strip():
|
|
333
|
+
return None
|
|
334
|
+
if not question.multi_select:
|
|
335
|
+
question.preset_answer = ""
|
|
336
|
+
question.is_answered = True
|
|
337
|
+
self._advance_question_or_preview()
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
if current_index < len(question.options):
|
|
341
|
+
if question.multi_select:
|
|
342
|
+
if not question.selected_indices:
|
|
343
|
+
question.selected_indices.add(current_index)
|
|
344
|
+
else:
|
|
345
|
+
question.selected_indices = {current_index}
|
|
346
|
+
question.custom_input = ""
|
|
347
|
+
question.preset_answer = ""
|
|
348
|
+
self._sync_custom_input_buffer()
|
|
349
|
+
|
|
350
|
+
if not self._question_is_answered(question):
|
|
351
|
+
return None
|
|
352
|
+
question.is_answered = True
|
|
353
|
+
self._advance_question_or_preview()
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
if current_index == chat_index:
|
|
357
|
+
question.preset_answer = _CHAT_MESSAGE
|
|
358
|
+
if not question.multi_select:
|
|
359
|
+
question.selected_indices.clear()
|
|
360
|
+
question.custom_input = ""
|
|
361
|
+
self._sync_custom_input_buffer()
|
|
362
|
+
question.is_answered = True
|
|
363
|
+
self._advance_question_or_preview()
|
|
364
|
+
return None
|
|
365
|
+
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
def handle_escape(self) -> QuestionAction:
|
|
369
|
+
return QuestionAction(kind="cancel", message=_CANCEL_MESSAGE)
|
|
370
|
+
|
|
371
|
+
def build_answers_message(self) -> str:
|
|
372
|
+
if len(self._state.questions) == 1:
|
|
373
|
+
question = self._state.questions[0]
|
|
374
|
+
if (
|
|
375
|
+
question.preset_answer.strip() == _CHAT_MESSAGE
|
|
376
|
+
and not question.custom_input.strip()
|
|
377
|
+
and not question.selected_indices
|
|
378
|
+
):
|
|
379
|
+
return _CHAT_MESSAGE
|
|
380
|
+
|
|
381
|
+
lines = ["User answered Comate's questions:"]
|
|
382
|
+
for idx, question in enumerate(self._state.questions, start=1):
|
|
383
|
+
answer = self._question_answer_summary(question, for_preview=False)
|
|
384
|
+
lines.append(f"- {idx}. {question.header}: {answer}")
|
|
385
|
+
lines.append("")
|
|
386
|
+
return "\n".join(lines)
|
|
387
|
+
|
|
388
|
+
def _switch_question(self, delta: int) -> None:
|
|
389
|
+
if not self._state.questions:
|
|
390
|
+
return
|
|
391
|
+
|
|
392
|
+
if self._state.is_preview_mode:
|
|
393
|
+
self._state.is_preview_mode = False
|
|
394
|
+
|
|
395
|
+
count = len(self._state.questions)
|
|
396
|
+
next_index = (self._state.current_question_index + delta) % count
|
|
397
|
+
self._state.current_question_index = next_index
|
|
398
|
+
self._state.current_option_index = 0
|
|
399
|
+
self._deactivate_custom_input()
|
|
400
|
+
self._sync_custom_input_buffer()
|
|
401
|
+
|
|
402
|
+
def _advance_question_or_preview(self) -> None:
|
|
403
|
+
if self._state.current_question_index < len(self._state.questions) - 1:
|
|
404
|
+
self._state.current_question_index += 1
|
|
405
|
+
self._state.current_option_index = 0
|
|
406
|
+
self._deactivate_custom_input()
|
|
407
|
+
self._sync_custom_input_buffer()
|
|
408
|
+
return
|
|
409
|
+
self.focus_submit()
|
|
410
|
+
|
|
411
|
+
def _active_question(self) -> QuestionState | None:
|
|
412
|
+
if not self._state.questions:
|
|
413
|
+
return None
|
|
414
|
+
index = self._state.current_question_index
|
|
415
|
+
if index < 0 or index >= len(self._state.questions):
|
|
416
|
+
return None
|
|
417
|
+
return self._state.questions[index]
|
|
418
|
+
|
|
419
|
+
def _question_is_answered(self, question: QuestionState) -> bool:
|
|
420
|
+
return (
|
|
421
|
+
bool(question.selected_indices)
|
|
422
|
+
or bool(question.custom_input.strip())
|
|
423
|
+
or bool(question.preset_answer.strip())
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _truncate_preview_text(content: str, *, max_chars: int = _PREVIEW_CUSTOM_INPUT_MAX_CHARS) -> str:
|
|
428
|
+
text = content.strip()
|
|
429
|
+
if len(text) <= max_chars:
|
|
430
|
+
return text
|
|
431
|
+
return f"{text[:max_chars]}..."
|
|
432
|
+
|
|
433
|
+
def _question_answer_summary(self, question: QuestionState, *, for_preview: bool) -> str:
|
|
434
|
+
selected_labels = [
|
|
435
|
+
question.options[idx].label
|
|
436
|
+
for idx in sorted(question.selected_indices)
|
|
437
|
+
if 0 <= idx < len(question.options)
|
|
438
|
+
]
|
|
439
|
+
custom_input = question.custom_input.strip()
|
|
440
|
+
if custom_input:
|
|
441
|
+
if for_preview:
|
|
442
|
+
selected_labels.append(self._truncate_preview_text(custom_input))
|
|
443
|
+
else:
|
|
444
|
+
selected_labels.append(custom_input)
|
|
445
|
+
preset_answer = question.preset_answer.strip()
|
|
446
|
+
if preset_answer:
|
|
447
|
+
selected_labels.append(preset_answer)
|
|
448
|
+
if not selected_labels:
|
|
449
|
+
return "未选择(默认决策)"
|
|
450
|
+
return ", ".join(selected_labels)
|
|
451
|
+
|
|
452
|
+
def _show_custom_input(self) -> bool:
|
|
453
|
+
return self._state.is_custom_input_active and not self._state.is_preview_mode
|
|
454
|
+
|
|
455
|
+
def _deactivate_custom_input(self) -> None:
|
|
456
|
+
self._state.is_custom_input_active = False
|
|
457
|
+
|
|
458
|
+
def _sync_custom_input_buffer(self) -> None:
|
|
459
|
+
question = self._active_question()
|
|
460
|
+
if question is None:
|
|
461
|
+
if self._custom_input_area.text:
|
|
462
|
+
self._custom_input_area.buffer.document = Document(text="", cursor_position=0)
|
|
463
|
+
return
|
|
464
|
+
text = question.custom_input
|
|
465
|
+
if self._custom_input_area.text == text:
|
|
466
|
+
return
|
|
467
|
+
self._custom_input_area.buffer.document = Document(text=text, cursor_position=len(text))
|
|
468
|
+
|
|
469
|
+
def _tabs_fragments(self) -> list[tuple[str, str]]:
|
|
470
|
+
fragments: list[tuple[str, str]] = [("class:question.tabs.nav", "← ")]
|
|
471
|
+
for idx, question in enumerate(self._state.questions):
|
|
472
|
+
if idx > 0:
|
|
473
|
+
fragments.append(("class:question.tabs", " "))
|
|
474
|
+
is_active = not self._state.is_preview_mode and idx == self._state.current_question_index
|
|
475
|
+
is_answered = self._question_is_answered(question)
|
|
476
|
+
marker = "☒" if is_answered else "☐"
|
|
477
|
+
style = "class:question.tab.active" if is_active else "class:question.tab"
|
|
478
|
+
fragments.append((style, f"{marker} {question.header}"))
|
|
479
|
+
|
|
480
|
+
if self._state.questions:
|
|
481
|
+
fragments.append(("class:question.tabs", " "))
|
|
482
|
+
submit_style = "class:question.tab.active" if self._state.is_preview_mode else "class:question.tab.submit"
|
|
483
|
+
submit_marker = "✔" if all(self._question_is_answered(q) for q in self._state.questions) else "○"
|
|
484
|
+
fragments.append((submit_style, f"{submit_marker} Submit"))
|
|
485
|
+
fragments.append(("class:question.tabs.nav", " →"))
|
|
486
|
+
return fragments
|
|
487
|
+
|
|
488
|
+
def _question_content_fragments(self) -> list[tuple[str, str]]:
|
|
489
|
+
question = self._active_question()
|
|
490
|
+
if question is None:
|
|
491
|
+
return [("class:question.title", "")]
|
|
492
|
+
title = question.question or f"请选择 {question.header}"
|
|
493
|
+
return [
|
|
494
|
+
("class:question.title", title),
|
|
495
|
+
("", "\n"),
|
|
496
|
+
(
|
|
497
|
+
"class:question.hint",
|
|
498
|
+
"多选: Space 选择 | Enter 确认下一题 | Tab 进入提交预览 | Esc 取消",
|
|
499
|
+
),
|
|
500
|
+
("", "\n"),
|
|
501
|
+
]
|
|
502
|
+
|
|
503
|
+
def _options_fragments(self) -> list[tuple[str, str]]:
|
|
504
|
+
question = self._active_question()
|
|
505
|
+
if question is None:
|
|
506
|
+
return [("class:question.body", "")]
|
|
507
|
+
|
|
508
|
+
fragments: list[tuple[str, str]] = []
|
|
509
|
+
for idx, option in enumerate(question.options):
|
|
510
|
+
cursor = "->" if idx == self._state.current_option_index else " "
|
|
511
|
+
selected = idx in question.selected_indices
|
|
512
|
+
if selected:
|
|
513
|
+
marker = "☒" if question.multi_select else "●"
|
|
514
|
+
else:
|
|
515
|
+
marker = f"{idx + 1}."
|
|
516
|
+
|
|
517
|
+
line_style = "class:question.option.cursor" if idx == self._state.current_option_index else "class:question.option"
|
|
518
|
+
if selected:
|
|
519
|
+
line_style = "class:question.option.selected"
|
|
520
|
+
|
|
521
|
+
fragments.append((line_style, f"{cursor} {marker} {option.label}\n"))
|
|
522
|
+
if option.description:
|
|
523
|
+
fragments.append(("class:question.option.description", f" {option.description}\n"))
|
|
524
|
+
|
|
525
|
+
return fragments
|
|
526
|
+
|
|
527
|
+
def _special_options_fragments(self) -> list[tuple[str, str]]:
|
|
528
|
+
question = self._active_question()
|
|
529
|
+
if question is None:
|
|
530
|
+
return [("class:question.body", "")]
|
|
531
|
+
|
|
532
|
+
custom_index = len(question.options)
|
|
533
|
+
chat_index = len(question.options) + 1
|
|
534
|
+
|
|
535
|
+
custom_cursor = "->" if self._state.current_option_index == custom_index else " "
|
|
536
|
+
custom_style = (
|
|
537
|
+
"class:question.option.cursor"
|
|
538
|
+
if self._state.current_option_index == custom_index
|
|
539
|
+
else "class:question.option"
|
|
540
|
+
)
|
|
541
|
+
custom_suffix = " (editing)" if self._state.is_custom_input_active else ""
|
|
542
|
+
|
|
543
|
+
chat_cursor = "->" if self._state.current_option_index == chat_index else " "
|
|
544
|
+
chat_selected = question.preset_answer.strip() == _CHAT_MESSAGE
|
|
545
|
+
if self._state.current_option_index == chat_index:
|
|
546
|
+
chat_style = "class:question.option.cursor"
|
|
547
|
+
elif chat_selected:
|
|
548
|
+
chat_style = "class:question.option.selected"
|
|
549
|
+
else:
|
|
550
|
+
chat_style = "class:question.option"
|
|
551
|
+
chat_marker = "☒" if chat_selected else f"{chat_index + 1}."
|
|
552
|
+
|
|
553
|
+
return [
|
|
554
|
+
(custom_style, f"{custom_cursor} {custom_index + 1}. Type something.{custom_suffix}\n"),
|
|
555
|
+
(chat_style, f"{chat_cursor} {chat_marker} Chat about this\n"),
|
|
556
|
+
]
|
|
557
|
+
|
|
558
|
+
def _preview_fragments(self) -> list[tuple[str, str]]:
|
|
559
|
+
fragments: list[tuple[str, str]] = [
|
|
560
|
+
("class:question.preview.title", "Review your answers\n\n"),
|
|
561
|
+
]
|
|
562
|
+
for question in self._state.questions:
|
|
563
|
+
answer = self._question_answer_summary(question, for_preview=True)
|
|
564
|
+
fragments.append(("class:question.preview.question", f"● {question.question or question.header}\n"))
|
|
565
|
+
fragments.append(("class:question.preview.answer", f" -> {answer}\n\n"))
|
|
566
|
+
|
|
567
|
+
fragments.append(("class:question.preview.title", "Ready to submit your answers?\n\n"))
|
|
568
|
+
|
|
569
|
+
submit_cursor = "->" if self._state.preview_option_index == 0 else " "
|
|
570
|
+
cancel_cursor = "->" if self._state.preview_option_index == 1 else " "
|
|
571
|
+
submit_style = (
|
|
572
|
+
"class:question.option.cursor"
|
|
573
|
+
if self._state.preview_option_index == 0
|
|
574
|
+
else "class:question.option"
|
|
575
|
+
)
|
|
576
|
+
cancel_style = (
|
|
577
|
+
"class:question.option.cursor"
|
|
578
|
+
if self._state.preview_option_index == 1
|
|
579
|
+
else "class:question.option"
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
fragments.append((submit_style, f"{submit_cursor} 1. Submit answers\n"))
|
|
583
|
+
fragments.append((cancel_style, f"{cancel_cursor} 2. Cancel\n"))
|
|
584
|
+
return fragments
|