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.
Files changed (44) hide show
  1. comate_cli/__init__.py +5 -0
  2. comate_cli/__main__.py +5 -0
  3. comate_cli/main.py +128 -0
  4. comate_cli/terminal_agent/__init__.py +2 -0
  5. comate_cli/terminal_agent/animations.py +283 -0
  6. comate_cli/terminal_agent/app.py +261 -0
  7. comate_cli/terminal_agent/assistant_render.py +243 -0
  8. comate_cli/terminal_agent/env_utils.py +37 -0
  9. comate_cli/terminal_agent/error_display.py +46 -0
  10. comate_cli/terminal_agent/event_renderer.py +867 -0
  11. comate_cli/terminal_agent/fragment_utils.py +25 -0
  12. comate_cli/terminal_agent/history_printer.py +150 -0
  13. comate_cli/terminal_agent/input_geometry.py +92 -0
  14. comate_cli/terminal_agent/layout_coordinator.py +188 -0
  15. comate_cli/terminal_agent/logging_adapter.py +147 -0
  16. comate_cli/terminal_agent/logo.py +58 -0
  17. comate_cli/terminal_agent/markdown_render.py +24 -0
  18. comate_cli/terminal_agent/mention_completer.py +293 -0
  19. comate_cli/terminal_agent/message_style.py +33 -0
  20. comate_cli/terminal_agent/models.py +89 -0
  21. comate_cli/terminal_agent/question_view.py +584 -0
  22. comate_cli/terminal_agent/rewind_store.py +712 -0
  23. comate_cli/terminal_agent/rpc_protocol.py +103 -0
  24. comate_cli/terminal_agent/rpc_stdio.py +280 -0
  25. comate_cli/terminal_agent/selection_menu.py +305 -0
  26. comate_cli/terminal_agent/session_view.py +99 -0
  27. comate_cli/terminal_agent/slash_commands.py +142 -0
  28. comate_cli/terminal_agent/startup.py +77 -0
  29. comate_cli/terminal_agent/status_bar.py +258 -0
  30. comate_cli/terminal_agent/text_effects.py +30 -0
  31. comate_cli/terminal_agent/tool_view.py +584 -0
  32. comate_cli/terminal_agent/tui.py +1006 -0
  33. comate_cli/terminal_agent/tui_parts/__init__.py +17 -0
  34. comate_cli/terminal_agent/tui_parts/commands.py +759 -0
  35. comate_cli/terminal_agent/tui_parts/history_sync.py +262 -0
  36. comate_cli/terminal_agent/tui_parts/input_behavior.py +324 -0
  37. comate_cli/terminal_agent/tui_parts/key_bindings.py +307 -0
  38. comate_cli/terminal_agent/tui_parts/render_panels.py +537 -0
  39. comate_cli/terminal_agent/tui_parts/slash_command_registry.py +45 -0
  40. comate_cli/terminal_agent/tui_parts/ui_mode.py +9 -0
  41. comate_cli-0.1.0.dist-info/METADATA +37 -0
  42. comate_cli-0.1.0.dist-info/RECORD +44 -0
  43. comate_cli-0.1.0.dist-info/WHEEL +4 -0
  44. 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