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,262 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from prompt_toolkit.application import run_in_terminal
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from comate_agent_sdk.context.items import ItemType
|
|
11
|
+
from comate_agent_sdk.llm.messages import AssistantMessage, UserMessage
|
|
12
|
+
|
|
13
|
+
from comate_cli.terminal_agent.history_printer import (
|
|
14
|
+
print_history_group_sync,
|
|
15
|
+
render_history_group,
|
|
16
|
+
)
|
|
17
|
+
from comate_cli.terminal_agent.logo import print_logo
|
|
18
|
+
from comate_cli.terminal_agent.markdown_render import render_markdown_to_plain
|
|
19
|
+
from comate_cli.terminal_agent.models import HistoryEntry
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HistorySyncMixin:
|
|
26
|
+
async def _replay_scrollback_after_rewind(self) -> None:
|
|
27
|
+
"""清屏并重建 scrollback:Logo + 当前会话历史。"""
|
|
28
|
+
self._renderer.reset_history_view()
|
|
29
|
+
self._printed_history_index = 0
|
|
30
|
+
|
|
31
|
+
def _clear_and_print_logo() -> None:
|
|
32
|
+
out = sys.__stdout__ or sys.stdout
|
|
33
|
+
out.write("\x1b[3J\x1b[2J\x1b[H")
|
|
34
|
+
out.flush()
|
|
35
|
+
scrollback_console = Console(
|
|
36
|
+
file=out,
|
|
37
|
+
force_terminal=True,
|
|
38
|
+
width=self._terminal_width(),
|
|
39
|
+
)
|
|
40
|
+
print_logo(scrollback_console)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
await run_in_terminal(_clear_and_print_logo, in_executor=False)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
logger.warning(
|
|
46
|
+
f"failed to clear terminal and replay logo: {exc}",
|
|
47
|
+
exc_info=True,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
self.add_resume_history("resume")
|
|
51
|
+
|
|
52
|
+
def add_resume_history(self, mode: str) -> None:
|
|
53
|
+
if mode != "resume":
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
items = self._session._agent._context.get_conversation_items_snapshot()
|
|
57
|
+
history: list[Any] = []
|
|
58
|
+
for item in items:
|
|
59
|
+
if item.item_type not in (
|
|
60
|
+
ItemType.USER_MESSAGE,
|
|
61
|
+
ItemType.ASSISTANT_MESSAGE,
|
|
62
|
+
ItemType.TOOL_RESULT,
|
|
63
|
+
):
|
|
64
|
+
continue
|
|
65
|
+
if item.item_type == ItemType.USER_MESSAGE:
|
|
66
|
+
message = getattr(item, "message", None)
|
|
67
|
+
if isinstance(message, UserMessage) and bool(getattr(message, "is_meta", False)):
|
|
68
|
+
continue
|
|
69
|
+
history.append(item)
|
|
70
|
+
if not history:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
self._renderer.append_system_message(f"history loaded: {len(history)} messages")
|
|
74
|
+
|
|
75
|
+
# 维护 tool_call_id 映射,用于匹配工具调用和结果
|
|
76
|
+
tool_call_info: dict[str, tuple[str, str]] = {} # tool_call_id → (tool_name, signature)
|
|
77
|
+
|
|
78
|
+
for item in history:
|
|
79
|
+
if item.item_type == ItemType.USER_MESSAGE:
|
|
80
|
+
message = getattr(item, "message", None)
|
|
81
|
+
content = str(item.content_text or "").strip()
|
|
82
|
+
if not content and isinstance(message, UserMessage):
|
|
83
|
+
content = str(message.text or "").strip()
|
|
84
|
+
if content:
|
|
85
|
+
self._renderer.seed_user_message(content)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
# 处理 AssistantMessage - 提取 tool_calls 信息但不渲染为动态组件
|
|
89
|
+
if item.item_type == ItemType.ASSISTANT_MESSAGE:
|
|
90
|
+
message = getattr(item, "message", None)
|
|
91
|
+
if isinstance(message, AssistantMessage) and message.tool_calls:
|
|
92
|
+
for tc in message.tool_calls:
|
|
93
|
+
tool_name = tc.function.name
|
|
94
|
+
args_str = tc.function.arguments
|
|
95
|
+
try:
|
|
96
|
+
import json
|
|
97
|
+
|
|
98
|
+
args_dict = json.loads(args_str) if args_str else {}
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
args_dict = {"_raw": args_str}
|
|
101
|
+
signature = self._build_resume_tool_signature(tool_name, args_dict)
|
|
102
|
+
# 存储映射,不调用 restore_tool_call(避免加入 _running_tools)
|
|
103
|
+
tool_call_info[tc.id] = (tool_name, signature)
|
|
104
|
+
|
|
105
|
+
# 显示 assistant text
|
|
106
|
+
assistant_text = self._extract_assistant_text(item).strip()
|
|
107
|
+
if assistant_text:
|
|
108
|
+
self._renderer.append_assistant_message(assistant_text)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
# 处理 TOOL_RESULT - 直接输出静态文本(无计时器)
|
|
112
|
+
if item.item_type == ItemType.TOOL_RESULT:
|
|
113
|
+
message = getattr(item, "message", None)
|
|
114
|
+
if message is None:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
tool_call_id = getattr(message, "tool_call_id", None)
|
|
118
|
+
is_error = getattr(message, "is_error", False)
|
|
119
|
+
|
|
120
|
+
# 从映射中获取工具信息
|
|
121
|
+
if tool_call_id and tool_call_id in tool_call_info:
|
|
122
|
+
tool_name, signature = tool_call_info[tool_call_id]
|
|
123
|
+
else:
|
|
124
|
+
# 回退:直接使用 tool_name
|
|
125
|
+
tool_name = getattr(message, "tool_name", item.tool_name or "UnknownTool")
|
|
126
|
+
signature = f"{tool_name}()"
|
|
127
|
+
|
|
128
|
+
# Extract diff from raw_envelope for Edit/MultiEdit
|
|
129
|
+
diff_lines: list[str] | None = None
|
|
130
|
+
if tool_name in ("Edit", "MultiEdit") and not is_error:
|
|
131
|
+
raw_envelope = getattr(item, "metadata", {}) or {}
|
|
132
|
+
envelope = raw_envelope.get("tool_raw_envelope")
|
|
133
|
+
if isinstance(envelope, dict):
|
|
134
|
+
data = envelope.get("data", {})
|
|
135
|
+
if isinstance(data, dict):
|
|
136
|
+
diff = data.get("diff")
|
|
137
|
+
if isinstance(diff, list) and len(diff) > 0:
|
|
138
|
+
diff_lines = diff
|
|
139
|
+
|
|
140
|
+
# 直接追加静态 HistoryEntry(无计时器)
|
|
141
|
+
self._renderer.append_static_tool_result(
|
|
142
|
+
signature,
|
|
143
|
+
is_error,
|
|
144
|
+
diff_lines=diff_lines,
|
|
145
|
+
)
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
self._drain_history_sync()
|
|
149
|
+
|
|
150
|
+
def _summarize_tool_args(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
151
|
+
"""Summarize tool arguments for display in history."""
|
|
152
|
+
from comate_cli.terminal_agent.tool_view import summarize_tool_args
|
|
153
|
+
|
|
154
|
+
summary = summarize_tool_args(tool_name, args, self._renderer._project_root).strip()
|
|
155
|
+
return summary
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _lookup_arg(args: dict[str, Any], *keys: str) -> Any:
|
|
159
|
+
for key in keys:
|
|
160
|
+
if key in args:
|
|
161
|
+
return args.get(key)
|
|
162
|
+
params = args.get("params")
|
|
163
|
+
if isinstance(params, dict):
|
|
164
|
+
for key in keys:
|
|
165
|
+
if key in params:
|
|
166
|
+
return params.get(key)
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def _build_resume_tool_signature(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
170
|
+
lowered = tool_name.lower()
|
|
171
|
+
if lowered != "task":
|
|
172
|
+
summary = self._summarize_tool_args(tool_name, args)
|
|
173
|
+
if summary:
|
|
174
|
+
return f"{tool_name}({summary})"
|
|
175
|
+
return f"{tool_name}()"
|
|
176
|
+
|
|
177
|
+
raw_subagent = self._lookup_arg(args, "subagent_type")
|
|
178
|
+
subagent_name = raw_subagent.strip() if isinstance(raw_subagent, str) else ""
|
|
179
|
+
if not subagent_name:
|
|
180
|
+
subagent_name = "Task"
|
|
181
|
+
|
|
182
|
+
raw_description = self._lookup_arg(args, "description")
|
|
183
|
+
description = raw_description.strip() if isinstance(raw_description, str) else ""
|
|
184
|
+
if description and description != subagent_name:
|
|
185
|
+
return f"{subagent_name}({description})"
|
|
186
|
+
return subagent_name
|
|
187
|
+
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _extract_assistant_text(item: Any) -> str:
|
|
190
|
+
"""从 ContextItem 提取用于显示的文本(不含 tool_calls JSON)"""
|
|
191
|
+
message = getattr(item, "message", None)
|
|
192
|
+
if message is None:
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
# 优先使用 message.text(纯文本,不含 tool_calls)
|
|
196
|
+
if hasattr(message, "text"):
|
|
197
|
+
text = message.text
|
|
198
|
+
if isinstance(text, str):
|
|
199
|
+
return text
|
|
200
|
+
|
|
201
|
+
# 回退:处理非标准 content
|
|
202
|
+
msg_content = getattr(message, "content", "")
|
|
203
|
+
if isinstance(msg_content, str):
|
|
204
|
+
return msg_content
|
|
205
|
+
if isinstance(msg_content, list):
|
|
206
|
+
text_parts: list[str] = []
|
|
207
|
+
for part in msg_content:
|
|
208
|
+
if isinstance(part, dict) and part.get("type") == "text":
|
|
209
|
+
text_parts.append(str(part.get("text", "")))
|
|
210
|
+
return "".join(text_parts)
|
|
211
|
+
|
|
212
|
+
return ""
|
|
213
|
+
|
|
214
|
+
async def _drain_history_async(self) -> None:
|
|
215
|
+
pending = self._pending_history_entries()
|
|
216
|
+
if not pending:
|
|
217
|
+
return
|
|
218
|
+
group = render_history_group(
|
|
219
|
+
console,
|
|
220
|
+
pending,
|
|
221
|
+
terminal_width=self._terminal_width(),
|
|
222
|
+
render_markdown_to_plain=render_markdown_to_plain,
|
|
223
|
+
)
|
|
224
|
+
if group is None:
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
def _print() -> None:
|
|
228
|
+
width = self._terminal_width()
|
|
229
|
+
out = sys.__stdout__ or sys.stdout
|
|
230
|
+
scrollback_console = Console(
|
|
231
|
+
file=out,
|
|
232
|
+
force_terminal=True,
|
|
233
|
+
width=width,
|
|
234
|
+
)
|
|
235
|
+
print_history_group_sync(scrollback_console, group)
|
|
236
|
+
|
|
237
|
+
await run_in_terminal(_print, in_executor=False)
|
|
238
|
+
|
|
239
|
+
def _drain_history_sync(self) -> None:
|
|
240
|
+
pending = self._pending_history_entries()
|
|
241
|
+
if not pending:
|
|
242
|
+
return
|
|
243
|
+
group = render_history_group(
|
|
244
|
+
console,
|
|
245
|
+
pending,
|
|
246
|
+
terminal_width=self._terminal_width(),
|
|
247
|
+
render_markdown_to_plain=render_markdown_to_plain,
|
|
248
|
+
)
|
|
249
|
+
if group is None:
|
|
250
|
+
return
|
|
251
|
+
print_history_group_sync(console, group)
|
|
252
|
+
|
|
253
|
+
def _pending_history_entries(self) -> list[HistoryEntry]:
|
|
254
|
+
entries = self._renderer.history_entries()
|
|
255
|
+
if self._printed_history_index >= len(entries):
|
|
256
|
+
return []
|
|
257
|
+
pending = entries[self._printed_history_index :]
|
|
258
|
+
self._printed_history_index = len(entries)
|
|
259
|
+
# 根据 _show_thinking 开关过滤 thinking 条目
|
|
260
|
+
if not getattr(self, "_show_thinking", False):
|
|
261
|
+
pending = [e for e in pending if e.entry_type != "thinking"]
|
|
262
|
+
return pending
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_right
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from prompt_toolkit.document import Document
|
|
7
|
+
|
|
8
|
+
from comate_cli.terminal_agent.input_geometry import (
|
|
9
|
+
compute_visual_line_ranges,
|
|
10
|
+
index_for_visual_col,
|
|
11
|
+
visual_col_for_index,
|
|
12
|
+
)
|
|
13
|
+
from comate_cli.terminal_agent.question_view import QuestionAction
|
|
14
|
+
from comate_cli.terminal_agent.tui_parts.ui_mode import UIMode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InputBehaviorMixin:
|
|
18
|
+
def _should_handle_history(self) -> bool:
|
|
19
|
+
"""检查是否应该处理历史浏览(而不是补全导航)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
True 表示可以浏览历史,False 表示应该优先处理补全
|
|
23
|
+
"""
|
|
24
|
+
buffer = self._input_area.buffer
|
|
25
|
+
|
|
26
|
+
# 如果有活动的补全菜单,不应该浏览历史
|
|
27
|
+
if buffer.complete_state is not None and buffer.complete_state.completions:
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
# 如果在补全上下文中(输入了 / 或 @),不应该浏览历史
|
|
31
|
+
document = buffer.document
|
|
32
|
+
if self._completion_context_active(
|
|
33
|
+
document.text_before_cursor,
|
|
34
|
+
document.text_after_cursor,
|
|
35
|
+
):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
return True
|
|
39
|
+
|
|
40
|
+
def _completion_context_active(
|
|
41
|
+
self,
|
|
42
|
+
text_before_cursor: str,
|
|
43
|
+
text_after_cursor: str,
|
|
44
|
+
) -> bool:
|
|
45
|
+
if text_after_cursor.strip():
|
|
46
|
+
return False
|
|
47
|
+
if text_before_cursor.startswith("/") and " " not in text_before_cursor:
|
|
48
|
+
return True
|
|
49
|
+
return self._mention_completer.extract_context(text_before_cursor) is not None
|
|
50
|
+
|
|
51
|
+
def _move_completion_selection(self, buffer: Any, *, backward: bool) -> bool:
|
|
52
|
+
"""尝试在补全菜单中移动选择项
|
|
53
|
+
|
|
54
|
+
行为:
|
|
55
|
+
- 如果补全菜单已显示,在菜单中导航
|
|
56
|
+
- 如果在补全上下文中(输入 / 或 @)但菜单未显示,触发补全并选中第一项
|
|
57
|
+
- 否则返回 False,允许调用者处理其他行为(如历史浏览)
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
buffer: 当前的 Buffer 对象
|
|
61
|
+
backward: True 表示向上导航,False 表示向下导航
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
True 表示已处理补全导航,False 表示未处理
|
|
65
|
+
"""
|
|
66
|
+
complete_state = buffer.complete_state
|
|
67
|
+
|
|
68
|
+
# 情况1: 已有补全菜单且有补全项
|
|
69
|
+
if complete_state is not None and complete_state.completions:
|
|
70
|
+
if backward:
|
|
71
|
+
buffer.complete_previous()
|
|
72
|
+
else:
|
|
73
|
+
buffer.complete_next()
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
# 情况2: 在补全上下文中但菜单未显示
|
|
77
|
+
document = buffer.document
|
|
78
|
+
if self._completion_context_active(
|
|
79
|
+
document.text_before_cursor,
|
|
80
|
+
document.text_after_cursor,
|
|
81
|
+
):
|
|
82
|
+
# 启动补全并选择第一项(关键改动:select_first=True)
|
|
83
|
+
buffer.start_completion(select_first=True)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def _enter_question_mode(self, questions: list[dict[str, Any]]) -> None:
|
|
89
|
+
if not self._question_ui.set_questions(questions):
|
|
90
|
+
return
|
|
91
|
+
self._ui_mode = UIMode.QUESTION
|
|
92
|
+
self._sync_focus_for_mode()
|
|
93
|
+
self._refresh_layers()
|
|
94
|
+
|
|
95
|
+
def _exit_question_mode(self) -> None:
|
|
96
|
+
self._ui_mode = UIMode.NORMAL
|
|
97
|
+
self._question_ui.clear()
|
|
98
|
+
self._sync_focus_for_mode()
|
|
99
|
+
|
|
100
|
+
def _sync_focus_for_mode(self) -> None:
|
|
101
|
+
if self._app is None:
|
|
102
|
+
return
|
|
103
|
+
if self._ui_mode == UIMode.QUESTION:
|
|
104
|
+
self._app.layout.focus(self._question_ui.focus_target())
|
|
105
|
+
return
|
|
106
|
+
if self._ui_mode == UIMode.SELECTION:
|
|
107
|
+
self._app.layout.focus(self._selection_ui.focus_target())
|
|
108
|
+
return
|
|
109
|
+
self._app.layout.focus(self._input_area.window)
|
|
110
|
+
|
|
111
|
+
def _handle_question_action(self, action: QuestionAction | None) -> None:
|
|
112
|
+
if action is None:
|
|
113
|
+
return
|
|
114
|
+
if action.kind == "cancel":
|
|
115
|
+
self._submit_question_reply(action.message, cancelled=True)
|
|
116
|
+
return
|
|
117
|
+
if action.kind == "submit":
|
|
118
|
+
self._submit_question_reply(action.message, cancelled=False)
|
|
119
|
+
|
|
120
|
+
def _submit_question_reply(self, message: str, *, cancelled: bool) -> None:
|
|
121
|
+
normalized = message.strip()
|
|
122
|
+
if not normalized:
|
|
123
|
+
return
|
|
124
|
+
self._exit_question_mode()
|
|
125
|
+
self._waiting_for_input = False
|
|
126
|
+
self._pending_questions = None
|
|
127
|
+
if cancelled:
|
|
128
|
+
self._renderer.append_system_message("用户取消问答,已发送拒绝回答消息。")
|
|
129
|
+
self._refresh_layers()
|
|
130
|
+
self._schedule_background(self._submit_user_message(normalized))
|
|
131
|
+
|
|
132
|
+
def _clear_input_area(self) -> None:
|
|
133
|
+
self._clear_paste_state()
|
|
134
|
+
self._last_input_len = 0
|
|
135
|
+
self._last_input_text = ""
|
|
136
|
+
|
|
137
|
+
buffer = self._input_area.buffer
|
|
138
|
+
buffer.cancel_completion()
|
|
139
|
+
self._suppress_input_change_hook = True
|
|
140
|
+
try:
|
|
141
|
+
buffer.set_document(Document("", cursor_position=0), bypass_readonly=True)
|
|
142
|
+
finally:
|
|
143
|
+
self._suppress_input_change_hook = False
|
|
144
|
+
|
|
145
|
+
def _next_paste_token(self) -> str:
|
|
146
|
+
self._paste_token_seq += 1
|
|
147
|
+
return f"paste_{self._paste_token_seq}"
|
|
148
|
+
|
|
149
|
+
def _clear_paste_state(self) -> None:
|
|
150
|
+
self._active_paste_token = None
|
|
151
|
+
self._paste_placeholder_text = None
|
|
152
|
+
self._paste_payload_by_token.clear()
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _find_inserted_segment(
|
|
156
|
+
previous_text: str,
|
|
157
|
+
current_text: str,
|
|
158
|
+
) -> tuple[int, int, str] | None:
|
|
159
|
+
if len(current_text) <= len(previous_text):
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
prefix = 0
|
|
163
|
+
max_prefix = min(len(previous_text), len(current_text))
|
|
164
|
+
while prefix < max_prefix and previous_text[prefix] == current_text[prefix]:
|
|
165
|
+
prefix += 1
|
|
166
|
+
|
|
167
|
+
previous_remaining = len(previous_text) - prefix
|
|
168
|
+
current_remaining = len(current_text) - prefix
|
|
169
|
+
suffix = 0
|
|
170
|
+
max_suffix = min(previous_remaining, current_remaining)
|
|
171
|
+
while (
|
|
172
|
+
suffix < max_suffix
|
|
173
|
+
and previous_text[len(previous_text) - 1 - suffix]
|
|
174
|
+
== current_text[len(current_text) - 1 - suffix]
|
|
175
|
+
):
|
|
176
|
+
suffix += 1
|
|
177
|
+
|
|
178
|
+
start = prefix
|
|
179
|
+
end = len(current_text) - suffix
|
|
180
|
+
if end <= start:
|
|
181
|
+
return None
|
|
182
|
+
return start, end, current_text[start:end]
|
|
183
|
+
|
|
184
|
+
def _resolve_submit_texts(self, raw_text: str) -> tuple[str, str]:
|
|
185
|
+
stripped = raw_text.strip()
|
|
186
|
+
token = self._active_paste_token
|
|
187
|
+
placeholder = self._paste_placeholder_text
|
|
188
|
+
if token is not None and placeholder is not None and placeholder in raw_text:
|
|
189
|
+
payload = self._paste_payload_by_token.get(token)
|
|
190
|
+
if payload is not None:
|
|
191
|
+
submit_text = raw_text.replace(placeholder, payload, 1).strip()
|
|
192
|
+
# 在 history 中展示真实发送内容,避免显示占位符文本。
|
|
193
|
+
return submit_text, submit_text
|
|
194
|
+
return stripped, stripped
|
|
195
|
+
|
|
196
|
+
def _handle_large_paste(self, buffer: Any) -> bool:
|
|
197
|
+
if self._suppress_input_change_hook:
|
|
198
|
+
return False
|
|
199
|
+
if self._busy:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
text = str(buffer.text)
|
|
203
|
+
previous_text = self._last_input_text
|
|
204
|
+
self._last_input_len = len(text)
|
|
205
|
+
self._last_input_text = text
|
|
206
|
+
|
|
207
|
+
placeholder = self._paste_placeholder_text
|
|
208
|
+
if self._active_paste_token is not None and placeholder and placeholder in text:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
if (
|
|
212
|
+
self._active_paste_token is not None
|
|
213
|
+
and self._paste_placeholder_text is not None
|
|
214
|
+
):
|
|
215
|
+
self._clear_paste_state()
|
|
216
|
+
|
|
217
|
+
threshold = max(1, int(self._paste_threshold_chars))
|
|
218
|
+
segment = self._find_inserted_segment(previous_text, text)
|
|
219
|
+
if segment is None:
|
|
220
|
+
return False
|
|
221
|
+
start, end, inserted_text = segment
|
|
222
|
+
if len(inserted_text) < threshold:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
token = self._next_paste_token()
|
|
226
|
+
self._active_paste_token = token
|
|
227
|
+
self._paste_payload_by_token[token] = inserted_text
|
|
228
|
+
placeholder = f"[Pasted Content {len(inserted_text)} chars]"
|
|
229
|
+
self._paste_placeholder_text = placeholder
|
|
230
|
+
replaced_text = f"{text[:start]}{placeholder}{text[end:]}"
|
|
231
|
+
|
|
232
|
+
self._suppress_input_change_hook = True
|
|
233
|
+
try:
|
|
234
|
+
buffer.cancel_completion()
|
|
235
|
+
buffer.set_document(
|
|
236
|
+
Document(replaced_text, cursor_position=start + len(placeholder)),
|
|
237
|
+
bypass_readonly=True,
|
|
238
|
+
)
|
|
239
|
+
self._last_input_len = len(replaced_text)
|
|
240
|
+
self._last_input_text = replaced_text
|
|
241
|
+
finally:
|
|
242
|
+
self._suppress_input_change_hook = False
|
|
243
|
+
|
|
244
|
+
self._invalidate()
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
def _move_cursor_visual(self, buffer: Any, *, backward: bool) -> bool:
|
|
248
|
+
text = str(buffer.text)
|
|
249
|
+
if not text:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
width = self._terminal_width()
|
|
253
|
+
max_cols = max(1, width - self._input_prompt_width)
|
|
254
|
+
ranges = compute_visual_line_ranges(text, max_cols=max_cols)
|
|
255
|
+
if len(ranges) <= 1:
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
cursor_index = int(buffer.cursor_position)
|
|
259
|
+
starts = [start for start, _end in ranges]
|
|
260
|
+
row = max(0, bisect_right(starts, cursor_index) - 1)
|
|
261
|
+
row_start, row_end = ranges[row]
|
|
262
|
+
|
|
263
|
+
current_col = visual_col_for_index(
|
|
264
|
+
text,
|
|
265
|
+
row_start,
|
|
266
|
+
row_end,
|
|
267
|
+
max_cols,
|
|
268
|
+
cursor_index,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
target_row = row - 1 if backward else row + 1
|
|
272
|
+
if target_row < 0 or target_row >= len(ranges):
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
target_start, target_end = ranges[target_row]
|
|
276
|
+
target_index = index_for_visual_col(
|
|
277
|
+
text, target_start, target_end, max_cols, current_col
|
|
278
|
+
)
|
|
279
|
+
buffer.cursor_position = target_index
|
|
280
|
+
return True
|
|
281
|
+
|
|
282
|
+
def _submit_from_input(self) -> None:
|
|
283
|
+
if self._ui_mode != UIMode.NORMAL:
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
raw_text = self._input_area.text
|
|
287
|
+
display_text, submit_text = self._resolve_submit_texts(raw_text)
|
|
288
|
+
if not submit_text.strip():
|
|
289
|
+
return
|
|
290
|
+
|
|
291
|
+
if self._input_area.buffer.complete_state is not None:
|
|
292
|
+
self._input_area.buffer.cancel_completion()
|
|
293
|
+
self._clear_input_area()
|
|
294
|
+
|
|
295
|
+
# busy 或 initializing 时:非斜杠命令 → 入队,斜杠命令 → 交由命令分发决定
|
|
296
|
+
is_busy = self._busy or self._initializing
|
|
297
|
+
if is_busy:
|
|
298
|
+
if display_text.lstrip().startswith("/"):
|
|
299
|
+
self._schedule_background(self._execute_command(display_text.strip()))
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
if not display_text.lstrip().startswith("/"):
|
|
303
|
+
queue_size = len(self._queued_messages)
|
|
304
|
+
if queue_size >= int(self._message_queue_max_size):
|
|
305
|
+
self._renderer.append_system_message(
|
|
306
|
+
f"消息队列已满({queue_size}/{self._message_queue_max_size}),请等待当前任务完成。",
|
|
307
|
+
severity="error",
|
|
308
|
+
)
|
|
309
|
+
self._refresh_layers()
|
|
310
|
+
return
|
|
311
|
+
self._queued_messages.append(submit_text)
|
|
312
|
+
self._invalidate()
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
if display_text.lstrip().startswith("/"):
|
|
316
|
+
self._schedule_background(self._execute_command(display_text.strip()))
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
self._schedule_background(
|
|
320
|
+
self._submit_user_message(
|
|
321
|
+
submit_text,
|
|
322
|
+
display_text=display_text,
|
|
323
|
+
)
|
|
324
|
+
)
|