mosscode 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.
- mosscode/__init__.py +10 -0
- mosscode/__main__.py +3 -0
- mosscode/agent.py +486 -0
- mosscode/agents/__init__.py +249 -0
- mosscode/agents/builtin/explore.md +16 -0
- mosscode/agents/builtin/general.md +12 -0
- mosscode/agents/builtin/plan.md +16 -0
- mosscode/agents/runtime.py +685 -0
- mosscode/agents/tasks.py +193 -0
- mosscode/appearance.py +184 -0
- mosscode/builtin_commands.py +1363 -0
- mosscode/cli.py +547 -0
- mosscode/commands.py +254 -0
- mosscode/compaction.py +259 -0
- mosscode/config.py +337 -0
- mosscode/context.py +102 -0
- mosscode/conversation.py +1046 -0
- mosscode/coordinator.py +54 -0
- mosscode/doctor.py +303 -0
- mosscode/hooks/__init__.py +22 -0
- mosscode/hooks/actions.py +151 -0
- mosscode/hooks/conditions.py +170 -0
- mosscode/hooks/config.py +404 -0
- mosscode/hooks/engine.py +401 -0
- mosscode/hooks/events.py +48 -0
- mosscode/launcher.py +177 -0
- mosscode/mcp_client.py +460 -0
- mosscode/mcp_config.py +216 -0
- mosscode/member_ui.py +289 -0
- mosscode/memory.py +291 -0
- mosscode/memory_extract.py +245 -0
- mosscode/messages.py +216 -0
- mosscode/paths.py +31 -0
- mosscode/permissions.py +733 -0
- mosscode/prompts.py +483 -0
- mosscode/providers.py +587 -0
- mosscode/scrollback.py +106 -0
- mosscode/security.py +282 -0
- mosscode/serialization.py +121 -0
- mosscode/session.py +563 -0
- mosscode/setup.py +248 -0
- mosscode/skills/__init__.py +455 -0
- mosscode/skills/builtin/commit/SKILL.md +15 -0
- mosscode/skills/builtin/review/SKILL.md +15 -0
- mosscode/skills/builtin/test/SKILL.md +13 -0
- mosscode/skills/builtin/test/scripts/detect.py +96 -0
- mosscode/skills/builtin/test/tool.json +9 -0
- mosscode/teams/__init__.py +216 -0
- mosscode/teams/backends.py +259 -0
- mosscode/teams/board.py +230 -0
- mosscode/teams/lock.py +173 -0
- mosscode/teams/mailbox.py +172 -0
- mosscode/teams/member.py +427 -0
- mosscode/teams/merge.py +207 -0
- mosscode/teams/pane.py +20 -0
- mosscode/teams/pane_app.py +167 -0
- mosscode/teams/process.py +371 -0
- mosscode/teams/runtime.py +1355 -0
- mosscode/teams/view.py +182 -0
- mosscode/tool_budget.py +86 -0
- mosscode/tools/__init__.py +38 -0
- mosscode/tools/agent.py +127 -0
- mosscode/tools/base.py +384 -0
- mosscode/tools/diffing.py +16 -0
- mosscode/tools/execution.py +99 -0
- mosscode/tools/files.py +287 -0
- mosscode/tools/memory.py +65 -0
- mosscode/tools/plan.py +30 -0
- mosscode/tools/process.py +216 -0
- mosscode/tools/search.py +229 -0
- mosscode/tools/search_worker.py +243 -0
- mosscode/tools/shell.py +121 -0
- mosscode/tools/skill.py +118 -0
- mosscode/tools/team.py +447 -0
- mosscode/tools/windows_job.py +75 -0
- mosscode/tui.py +2325 -0
- mosscode/usage.py +68 -0
- mosscode/windows_inline.py +148 -0
- mosscode/worktree/__init__.py +29 -0
- mosscode/worktree/changes.py +59 -0
- mosscode/worktree/git.py +125 -0
- mosscode/worktree/manager.py +462 -0
- mosscode/worktree/meta.py +82 -0
- mosscode/worktree/names.py +52 -0
- mosscode/worktree/setup.py +119 -0
- mosscode-0.1.0.dist-info/METADATA +92 -0
- mosscode-0.1.0.dist-info/RECORD +91 -0
- mosscode-0.1.0.dist-info/WHEEL +5 -0
- mosscode-0.1.0.dist-info/entry_points.txt +3 -0
- mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
- mosscode-0.1.0.dist-info/top_level.txt +1 -0
mosscode/tui.py
ADDED
|
@@ -0,0 +1,2325 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from contextlib import aclosing, suppress
|
|
8
|
+
from time import monotonic
|
|
9
|
+
from typing import TypeVar
|
|
10
|
+
|
|
11
|
+
from rich.console import Group
|
|
12
|
+
from rich.padding import Padding
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
from textual import events
|
|
16
|
+
from textual.app import App, ComposeResult
|
|
17
|
+
from textual.binding import Binding
|
|
18
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
19
|
+
from textual.content import Content
|
|
20
|
+
from textual.css.query import NoMatches
|
|
21
|
+
from textual.message import Message as UIMessage
|
|
22
|
+
from textual.widgets import Markdown, OptionList, Static, TextArea
|
|
23
|
+
from textual.widgets.markdown import MarkdownFence
|
|
24
|
+
|
|
25
|
+
from .agents.tasks import Announcer
|
|
26
|
+
from .appearance import (
|
|
27
|
+
COMMAND_HIGHLIGHT_COLOR,
|
|
28
|
+
ERROR_COLOR,
|
|
29
|
+
OPERATION_COLOR,
|
|
30
|
+
PARAMETER_COLOR,
|
|
31
|
+
TERMINAL_CSS,
|
|
32
|
+
TOOL_SUCCESS_COLOR,
|
|
33
|
+
USER_MESSAGE_BACKGROUND,
|
|
34
|
+
USER_MESSAGE_COLOR,
|
|
35
|
+
compact_notice,
|
|
36
|
+
moss_themes,
|
|
37
|
+
startup_banner,
|
|
38
|
+
)
|
|
39
|
+
from .builtin_commands import BUILTIN, clean_text, confirm_tasks, dispatch, offer_trust, open_sessions
|
|
40
|
+
from .commands import CommandContext, CommandRegistry, Hint, Parsed, parse_input, unknown_note
|
|
41
|
+
from .config import ConfigError, ProviderConfig, Settings
|
|
42
|
+
from .conversation import Conversation, MemoryUpdated, Notice, Resumed
|
|
43
|
+
from .coordinator import BADGE as COORDINATOR_BADGE
|
|
44
|
+
from .member_ui import PANEL_HELP, STOP_CONFIRM, VIEW_MESSAGES, VIEW_SHARE, MemberList, MemberPanel, MemberView
|
|
45
|
+
from .memory import SCOPE_LABELS
|
|
46
|
+
from .messages import (
|
|
47
|
+
NOTIFICATION,
|
|
48
|
+
CompactFailed,
|
|
49
|
+
CompactFinished,
|
|
50
|
+
CompactStarted,
|
|
51
|
+
Completed,
|
|
52
|
+
Delta,
|
|
53
|
+
IterationProgress,
|
|
54
|
+
Message,
|
|
55
|
+
ProviderError,
|
|
56
|
+
ResponseBoundary,
|
|
57
|
+
Retrying,
|
|
58
|
+
RunStopped,
|
|
59
|
+
ToolCall,
|
|
60
|
+
ToolProgress,
|
|
61
|
+
ToolResult,
|
|
62
|
+
UsageUpdate,
|
|
63
|
+
UserInput,
|
|
64
|
+
)
|
|
65
|
+
from .permissions import CHOICES, EDIT_TOOLS, MODE_BADGES, ApprovalRequest, safe_display
|
|
66
|
+
from .scrollback import TranscriptMarkdown, commit_transcript, message_row, redraw_transcript
|
|
67
|
+
from .teams import TeamError
|
|
68
|
+
from .teams.runtime import MemberRow
|
|
69
|
+
from .teams.view import TranscriptTail
|
|
70
|
+
from .tool_budget import persisted_note
|
|
71
|
+
from .tools import shell_label
|
|
72
|
+
from .usage import cache_summary
|
|
73
|
+
|
|
74
|
+
Result = TypeVar("Result")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TerminalApp(App[Result]):
|
|
78
|
+
ENABLE_COMMAND_PALETTE = False
|
|
79
|
+
ESCAPE_TO_MINIMIZE = False
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
# Preserve terminal-default colors instead of converting them to RGB.
|
|
83
|
+
super().__init__(ansi_color=True)
|
|
84
|
+
|
|
85
|
+
def get_driver_class(self):
|
|
86
|
+
if sys.platform == "win32":
|
|
87
|
+
from .windows_inline import WindowsInlineDriver
|
|
88
|
+
|
|
89
|
+
return WindowsInlineDriver
|
|
90
|
+
return super().get_driver_class()
|
|
91
|
+
|
|
92
|
+
def on_mount(self) -> None:
|
|
93
|
+
for theme in moss_themes():
|
|
94
|
+
self.register_theme(theme)
|
|
95
|
+
self.theme = "moss-dark"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
CHOICE_LABELS = {
|
|
99
|
+
"once": "本次允许 · 仅执行当前调用",
|
|
100
|
+
"session": "本会话允许 · 同工具与目标,退出后失效",
|
|
101
|
+
"permanent": "永久允许 · 保存到本项目本地配置",
|
|
102
|
+
"deny": "拒绝 · 让模型调整策略",
|
|
103
|
+
"execute": "批准计划并执行 · 恢复为进入计划前的权限",
|
|
104
|
+
"accept_edits": "批准并自动接受编辑 · 切换为接受编辑档位",
|
|
105
|
+
"merge": "合并 · 把成员的提交合并到当前分支",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
DECLINED_HINT = "└ 没有合并。想说明原因或改动要求,直接在输入框告诉 Lead。"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def choice_label(request: ApprovalRequest, choice: str) -> str:
|
|
113
|
+
if request.operation.tool == "exit_plan_mode" and choice == "deny":
|
|
114
|
+
return "继续规划 · 拒绝并让模型修改计划"
|
|
115
|
+
if request.operation.tool == "team_merge" and choice == "deny":
|
|
116
|
+
return "不合并 · Lead 会停下来问你"
|
|
117
|
+
if request.operation.risk and choice == "once":
|
|
118
|
+
return "本次允许 · 高风险命令每次都需确认"
|
|
119
|
+
if choice == "prefix":
|
|
120
|
+
return f"永久允许以 {request.operation.prefix} 开头的命令 · 保存到本项目本地配置"
|
|
121
|
+
if choice == "accept_edits" and request.operation.tool in EDIT_TOOLS:
|
|
122
|
+
return "本会话自动接受编辑 · 切换为接受编辑档位,命令仍需确认"
|
|
123
|
+
if (request.operation.server or request.operation.skill) and choice == "session":
|
|
124
|
+
return "本会话允许该工具 · 任意参数,退出后失效"
|
|
125
|
+
if (request.operation.server or request.operation.skill) and choice == "permanent":
|
|
126
|
+
return "永久允许该工具 · 任意参数,保存到本项目本地配置"
|
|
127
|
+
return CHOICE_LABELS[choice]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def preview_text(preview: str) -> Text:
|
|
131
|
+
"""Approval preview; diffs are colored line by line."""
|
|
132
|
+
text = Text()
|
|
133
|
+
diff = any(line.startswith(("@@", "--- a/", "+++ b/")) for line in preview.splitlines()[:3]) or preview.startswith(
|
|
134
|
+
"新建文件 "
|
|
135
|
+
)
|
|
136
|
+
for index, line in enumerate(preview.splitlines()):
|
|
137
|
+
style = None
|
|
138
|
+
if diff and line.startswith("+") and not line.startswith("+++"):
|
|
139
|
+
style = TOOL_SUCCESS_COLOR
|
|
140
|
+
elif diff and line.startswith("-") and not line.startswith("---"):
|
|
141
|
+
style = ERROR_COLOR
|
|
142
|
+
elif diff and line.startswith(("@@", "---", "+++", "…")):
|
|
143
|
+
style = "dim"
|
|
144
|
+
text.append(("\n" if index else "") + line, style=style)
|
|
145
|
+
return text
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Shift+Tab order; bypassPermissions is entered only explicitly and leaves to default.
|
|
149
|
+
MODE_CYCLE = {"default": "acceptEdits", "acceptEdits": "plan", "plan": "default", "bypassPermissions": "default"}
|
|
150
|
+
DIFF_ROWS = 20 # Diff lines shown under an edit/write result.
|
|
151
|
+
NEW_FILE_ROWS = 8
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def diff_rows(diff: str, limit: int = DIFF_ROWS) -> Text:
|
|
155
|
+
"""Unified diff as numbered, colored rows; hunks are separated by ⋯."""
|
|
156
|
+
rows: list[tuple[str, str | None]] = []
|
|
157
|
+
old = new = 0
|
|
158
|
+
notes = []
|
|
159
|
+
for line in diff.splitlines():
|
|
160
|
+
if line.startswith(("--- ", "+++ ")):
|
|
161
|
+
continue
|
|
162
|
+
if line.startswith("@@"):
|
|
163
|
+
try:
|
|
164
|
+
ranges = line.split("@@")[1].split()
|
|
165
|
+
old = int(ranges[0][1:].split(",")[0])
|
|
166
|
+
new = int(ranges[1][1:].split(",")[0])
|
|
167
|
+
except (IndexError, ValueError):
|
|
168
|
+
continue
|
|
169
|
+
if rows:
|
|
170
|
+
rows.append((" ⋯", None))
|
|
171
|
+
elif line.startswith("-"):
|
|
172
|
+
rows.append((f"{old:>5} - {line[1:]}", ERROR_COLOR))
|
|
173
|
+
old += 1
|
|
174
|
+
elif line.startswith("+"):
|
|
175
|
+
rows.append((f"{new:>5} + {line[1:]}", TOOL_SUCCESS_COLOR))
|
|
176
|
+
new += 1
|
|
177
|
+
elif line.startswith(" "):
|
|
178
|
+
rows.append((f"{new:>5} {line[1:]}", None))
|
|
179
|
+
old, new = old + 1, new + 1
|
|
180
|
+
elif line.strip():
|
|
181
|
+
notes.append(line) # e.g. the generator's own truncation note
|
|
182
|
+
text = Text()
|
|
183
|
+
for row, style in rows[:limit]:
|
|
184
|
+
text.append("\n " + row[:160], style=style)
|
|
185
|
+
if len(rows) > limit:
|
|
186
|
+
text.append(f"\n … 还有 {len(rows) - limit} 行 diff 未显示")
|
|
187
|
+
for note in notes:
|
|
188
|
+
text.append("\n " + note)
|
|
189
|
+
return text
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class PromptArea(TextArea):
|
|
193
|
+
"""Multi-line prompt. Enter sends; Shift+Enter, Ctrl+J, Alt+Enter or a trailing \\ insert a newline.
|
|
194
|
+
|
|
195
|
+
↑ on the top row and ↓ on the bottom row browse this session's input history;
|
|
196
|
+
while messages are queued, ↑ on the top row first takes them back into the prompt.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
NEWLINE_KEYS = {"shift+enter", "ctrl+j", "alt+enter"}
|
|
200
|
+
|
|
201
|
+
class Submitted(UIMessage):
|
|
202
|
+
def __init__(self, text_area: "PromptArea", value: str) -> None:
|
|
203
|
+
super().__init__()
|
|
204
|
+
self.text_area, self.value = text_area, value
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def control(self) -> "PromptArea":
|
|
208
|
+
return self.text_area
|
|
209
|
+
|
|
210
|
+
def __init__(self, **kwargs) -> None:
|
|
211
|
+
super().__init__(soft_wrap=True, tab_behavior="focus", compact=True, highlight_cursor_line=False, **kwargs)
|
|
212
|
+
self.input_history: list[str] = []
|
|
213
|
+
self._history_index: int | None = None
|
|
214
|
+
self._draft = ""
|
|
215
|
+
# Set by the app: removes and returns the running task's queued messages.
|
|
216
|
+
self.take_queued: Callable[[], tuple[str, ...]] | None = None
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def value(self) -> str:
|
|
220
|
+
return self.text
|
|
221
|
+
|
|
222
|
+
@value.setter
|
|
223
|
+
def value(self, text: str) -> None:
|
|
224
|
+
self.load_text(text)
|
|
225
|
+
self.move_cursor(self.document.end)
|
|
226
|
+
|
|
227
|
+
async def _on_key(self, event: events.Key) -> None:
|
|
228
|
+
if event.key in self.NEWLINE_KEYS:
|
|
229
|
+
event.stop()
|
|
230
|
+
event.prevent_default()
|
|
231
|
+
self.insert("\n")
|
|
232
|
+
return
|
|
233
|
+
if event.key == "enter":
|
|
234
|
+
event.stop()
|
|
235
|
+
event.prevent_default()
|
|
236
|
+
row, column = self.cursor_location
|
|
237
|
+
if column and not self.selected_text and self.document.get_line(row)[column - 1] == "\\":
|
|
238
|
+
self.replace("\n", (row, column - 1), (row, column))
|
|
239
|
+
return
|
|
240
|
+
self.post_message(self.Submitted(self, self.text))
|
|
241
|
+
return
|
|
242
|
+
await super()._on_key(event)
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def recalled(self) -> bool:
|
|
246
|
+
"""The text is an unedited history entry, so ↑ / ↓ keep browsing instead of opening command hints."""
|
|
247
|
+
return self._history_index is not None and self.text == self.input_history[self._history_index]
|
|
248
|
+
|
|
249
|
+
def remember(self, text: str) -> None:
|
|
250
|
+
if text.strip() and (not self.input_history or self.input_history[-1] != text):
|
|
251
|
+
self.input_history.append(text)
|
|
252
|
+
self._history_index = None
|
|
253
|
+
self._draft = ""
|
|
254
|
+
|
|
255
|
+
def restore_queued(self, queued: tuple[str, ...]) -> None:
|
|
256
|
+
"""Put queued messages back in front of any draft."""
|
|
257
|
+
self._history_index = None
|
|
258
|
+
self.value = "\n\n".join((*queued, self.text) if self.text.strip() else queued)
|
|
259
|
+
|
|
260
|
+
def action_cursor_up(self, select: bool = False) -> None:
|
|
261
|
+
location = self.cursor_location
|
|
262
|
+
on_top = self.navigator.is_first_document_line(location) and self.navigator.is_first_wrapped_line(location)
|
|
263
|
+
if not select and on_top and self.take_queued is not None:
|
|
264
|
+
queued = self.take_queued()
|
|
265
|
+
if queued:
|
|
266
|
+
# Queued messages come back for editing first; the running task continues.
|
|
267
|
+
self.restore_queued(queued)
|
|
268
|
+
return
|
|
269
|
+
if not select and self.input_history and on_top:
|
|
270
|
+
self._browse(-1)
|
|
271
|
+
return
|
|
272
|
+
super().action_cursor_up(select)
|
|
273
|
+
|
|
274
|
+
def action_cursor_down(self, select: bool = False) -> None:
|
|
275
|
+
location = self.cursor_location
|
|
276
|
+
if (
|
|
277
|
+
not select
|
|
278
|
+
and self._history_index is not None
|
|
279
|
+
and self.navigator.is_last_document_line(location)
|
|
280
|
+
and self.navigator.is_last_wrapped_line(location)
|
|
281
|
+
):
|
|
282
|
+
self._browse(1)
|
|
283
|
+
return
|
|
284
|
+
super().action_cursor_down(select)
|
|
285
|
+
|
|
286
|
+
def _browse(self, step: int) -> None:
|
|
287
|
+
if self._history_index is None:
|
|
288
|
+
if step > 0:
|
|
289
|
+
return
|
|
290
|
+
self._draft = self.text
|
|
291
|
+
index = len(self.input_history) - 1
|
|
292
|
+
else:
|
|
293
|
+
index = self._history_index + step
|
|
294
|
+
if index < 0:
|
|
295
|
+
return
|
|
296
|
+
if index >= len(self.input_history):
|
|
297
|
+
# Past the newest entry: back to the unsent draft.
|
|
298
|
+
self._history_index = None
|
|
299
|
+
self.value = self._draft
|
|
300
|
+
return
|
|
301
|
+
self._history_index = index
|
|
302
|
+
self.value = self.input_history[index]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class PlanEntry(Static):
|
|
306
|
+
"""The submitted plan, committed to scrollback before approval so it can be read in full."""
|
|
307
|
+
|
|
308
|
+
def __init__(self, plan: str):
|
|
309
|
+
super().__init__(classes="entry plan-entry")
|
|
310
|
+
self.transcript = Group(
|
|
311
|
+
Text("计划", style=f"bold {OPERATION_COLOR}"), message_row("│", TranscriptMarkdown(plan))
|
|
312
|
+
)
|
|
313
|
+
self.update(self.transcript)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
CANCELLED_COMPACT = "└ 已取消压缩,对话历史未改变。"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class CompactRow(Static):
|
|
320
|
+
"""A running compaction, shown in the conversation and turned into its result line in place."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, manual: bool):
|
|
323
|
+
super().__init__(classes="local-note")
|
|
324
|
+
self.label = "正在压缩上下文" if manual else "上下文接近上限,正在压缩"
|
|
325
|
+
self.started = monotonic()
|
|
326
|
+
self.transcript: Text | None = None # Set once finished; scrollback waits until then.
|
|
327
|
+
self.tick()
|
|
328
|
+
|
|
329
|
+
def tick(self) -> None:
|
|
330
|
+
if self.transcript is None:
|
|
331
|
+
self.update(Text(f"✻ {self.label} · {monotonic() - self.started:.1f}s", style=OPERATION_COLOR))
|
|
332
|
+
|
|
333
|
+
def finish(self, text: str, style: str | None = None) -> None:
|
|
334
|
+
self.transcript = Text(text, style=style or "")
|
|
335
|
+
self.update(self.transcript)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class KeyboardOptions(OptionList):
|
|
339
|
+
async def _on_click(self, event: events.Click) -> None:
|
|
340
|
+
# Choices require Enter. Mouse clicks may focus the list but cannot execute an action.
|
|
341
|
+
event.prevent_default()
|
|
342
|
+
event.stop()
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class SessionInfo(Static):
|
|
346
|
+
class Resized(UIMessage):
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
def on_resize(self) -> None:
|
|
350
|
+
self.post_message(self.Resized())
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class ModelPanel(Vertical):
|
|
354
|
+
"""The choice list under the prompt, shared by /model and /resume."""
|
|
355
|
+
|
|
356
|
+
def compose(self) -> ComposeResult:
|
|
357
|
+
yield Static("切换模型 · 对话历史保留", id="model-title", markup=False)
|
|
358
|
+
yield KeyboardOptions(id="models")
|
|
359
|
+
yield Static("↑ / ↓ 选择 · Enter 确认 · Esc 收起", id="model-help")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class PlainCodeFence(MarkdownFence):
|
|
363
|
+
@classmethod
|
|
364
|
+
def highlight(cls, code: str, language: str, ansi: bool = False, dark: bool = False) -> Content:
|
|
365
|
+
return Content(code)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class DialogueMarkdown(Markdown):
|
|
369
|
+
BLOCKS = {**Markdown.BLOCKS, "fence": PlainCodeFence, "code_block": PlainCodeFence}
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class ProviderSelectApp(TerminalApp[ProviderConfig]):
|
|
373
|
+
TITLE = "MossCode"
|
|
374
|
+
SUB_TITLE = "选择本次会话的提供商与模型"
|
|
375
|
+
CSS = (
|
|
376
|
+
TERMINAL_CSS
|
|
377
|
+
+ """
|
|
378
|
+
Screen { background: $background; padding: 1 2; }
|
|
379
|
+
Screen:inline { height: auto; border: none; }
|
|
380
|
+
#selection { height: auto; max-height: 50vh; border: none; background: $background; }
|
|
381
|
+
#selection-help { margin: 1 0; height: auto; color: $text-muted; }
|
|
382
|
+
#banner { height: auto; margin-bottom: 1; }
|
|
383
|
+
"""
|
|
384
|
+
)
|
|
385
|
+
BINDINGS = [
|
|
386
|
+
Binding("escape", "quit", "退出", priority=True),
|
|
387
|
+
Binding("ctrl+c", "quit", "退出", priority=True),
|
|
388
|
+
Binding("ctrl+q", "quit", "退出", priority=True),
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
def __init__(self, settings: Settings) -> None:
|
|
392
|
+
super().__init__()
|
|
393
|
+
self.settings = settings
|
|
394
|
+
|
|
395
|
+
def compose(self) -> ComposeResult:
|
|
396
|
+
yield Static(startup_banner("选择提供商"), id="banner")
|
|
397
|
+
yield Static("↑ / ↓ 选择,Enter 确认;本次会话使用选定的配置。", id="selection-help")
|
|
398
|
+
yield KeyboardOptions(
|
|
399
|
+
*(Text(item.name) for item in self.settings.providers),
|
|
400
|
+
id="selection",
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def on_mount(self) -> None:
|
|
404
|
+
super().on_mount()
|
|
405
|
+
options = self.query_one("#selection", OptionList)
|
|
406
|
+
options.highlighted = next(
|
|
407
|
+
(
|
|
408
|
+
index
|
|
409
|
+
for index, item in enumerate(self.settings.providers)
|
|
410
|
+
if item.name == self.settings.default_provider
|
|
411
|
+
),
|
|
412
|
+
0,
|
|
413
|
+
)
|
|
414
|
+
options.focus()
|
|
415
|
+
|
|
416
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
417
|
+
self.exit(self.settings.providers[event.option_index])
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class ChatEntry(Vertical):
|
|
421
|
+
def __init__(self, speaker: str, text: str = "") -> None:
|
|
422
|
+
super().__init__(classes="entry user-entry" if speaker == "You" else "entry assistant-entry")
|
|
423
|
+
self.speaker = speaker
|
|
424
|
+
self.body = text
|
|
425
|
+
self.thinking = ""
|
|
426
|
+
self.started = monotonic()
|
|
427
|
+
self.phase = "waiting"
|
|
428
|
+
self.thinking_started: float | None = None
|
|
429
|
+
self.thinking_elapsed = 0.0
|
|
430
|
+
self.completed_elapsed: float | None = None
|
|
431
|
+
self.interruption_note = ""
|
|
432
|
+
self.tool_request = False
|
|
433
|
+
|
|
434
|
+
def compose(self) -> ComposeResult:
|
|
435
|
+
if self.speaker == "You":
|
|
436
|
+
with Horizontal(classes="message-row"):
|
|
437
|
+
yield Static("❯", classes="message-marker")
|
|
438
|
+
yield Static(self.body, classes="user-message", markup=False)
|
|
439
|
+
return
|
|
440
|
+
for classes in ("thinking-label", "thinking"):
|
|
441
|
+
widget = Static("", classes=classes, markup=False)
|
|
442
|
+
widget.display = False
|
|
443
|
+
yield widget
|
|
444
|
+
with Horizontal(classes="message-row"):
|
|
445
|
+
yield Static("●", classes="message-marker")
|
|
446
|
+
with Vertical(classes="response-content"):
|
|
447
|
+
yield Static(self.body or "等待回复…", classes="raw", markup=False)
|
|
448
|
+
answer = DialogueMarkdown("", classes="answer")
|
|
449
|
+
answer.display = False
|
|
450
|
+
yield answer
|
|
451
|
+
summary = Static("", classes="turn-summary", markup=False)
|
|
452
|
+
summary.display = False
|
|
453
|
+
yield summary
|
|
454
|
+
|
|
455
|
+
def refresh_timer(self) -> None:
|
|
456
|
+
if self.thinking_started is not None:
|
|
457
|
+
elapsed = self.thinking_elapsed + monotonic() - self.thinking_started
|
|
458
|
+
self.query_one(".thinking-label", Static).update(f"思考中 · {elapsed:.1f}s")
|
|
459
|
+
|
|
460
|
+
def _finish_thinking(self) -> None:
|
|
461
|
+
if self.thinking_started is not None:
|
|
462
|
+
self.thinking_elapsed += monotonic() - self.thinking_started
|
|
463
|
+
self.thinking_started = None
|
|
464
|
+
self.query_one(".thinking-label", Static).update(f"思考用时 {self.thinking_elapsed:.1f}s")
|
|
465
|
+
self.query_one(".thinking", Static).display = False
|
|
466
|
+
|
|
467
|
+
def append(self, event: Delta) -> None:
|
|
468
|
+
if not event.text:
|
|
469
|
+
return
|
|
470
|
+
if event.kind == "thinking":
|
|
471
|
+
self.phase = "thinking"
|
|
472
|
+
if self.thinking_started is None:
|
|
473
|
+
self.thinking_started = monotonic()
|
|
474
|
+
self.thinking += event.text
|
|
475
|
+
self.query_one(".thinking-label", Static).display = True
|
|
476
|
+
self.refresh_timer()
|
|
477
|
+
widget = self.query_one(".thinking", Static)
|
|
478
|
+
widget.display = True
|
|
479
|
+
widget.update(self.thinking)
|
|
480
|
+
self.query_one(".message-row").display = bool(self.body)
|
|
481
|
+
else:
|
|
482
|
+
self._finish_thinking()
|
|
483
|
+
self.phase = "streaming"
|
|
484
|
+
self.body += event.text
|
|
485
|
+
self.query_one(".message-row").display = True
|
|
486
|
+
self.query_one(".raw", Static).update(self.body)
|
|
487
|
+
|
|
488
|
+
async def finish(self, message: Message) -> None:
|
|
489
|
+
self._finish_thinking()
|
|
490
|
+
self.phase = "complete"
|
|
491
|
+
self.body = message.text
|
|
492
|
+
markdown = self.query_one(".answer", Markdown)
|
|
493
|
+
await markdown.update(self.body or "(服务返回了空正文)")
|
|
494
|
+
markdown.display = True
|
|
495
|
+
self.query_one(".message-row").display = True
|
|
496
|
+
self.query_one(".raw", Static).display = False
|
|
497
|
+
summary = self.query_one(".turn-summary", Static)
|
|
498
|
+
self.completed_elapsed = monotonic() - self.started
|
|
499
|
+
summary.update(f"✻ 完成 · {self.completed_elapsed:.1f}s")
|
|
500
|
+
summary.display = True
|
|
501
|
+
|
|
502
|
+
async def seal_tool_request(self, message: Message) -> None:
|
|
503
|
+
self._finish_thinking()
|
|
504
|
+
self.phase = "tools"
|
|
505
|
+
self.tool_request = True
|
|
506
|
+
self.body = message.text
|
|
507
|
+
self.query_one(".message-row").display = bool(self.body)
|
|
508
|
+
if not self.body and not self.thinking:
|
|
509
|
+
self.display = False
|
|
510
|
+
if self.body:
|
|
511
|
+
markdown = self.query_one(".answer", Markdown)
|
|
512
|
+
await markdown.update(self.body)
|
|
513
|
+
markdown.display = True
|
|
514
|
+
self.query_one(".raw", Static).display = False
|
|
515
|
+
|
|
516
|
+
def interrupted(self, note: str) -> None:
|
|
517
|
+
self.interruption_note = note
|
|
518
|
+
self.display = True
|
|
519
|
+
self._finish_thinking()
|
|
520
|
+
self.phase = "interrupted"
|
|
521
|
+
self.query_one(".message-row").display = True
|
|
522
|
+
raw = self.query_one(".raw", Static)
|
|
523
|
+
raw.update((self.body + "\n\n" if self.body else "") + note)
|
|
524
|
+
raw.display = True
|
|
525
|
+
self.query_one(".answer").display = False
|
|
526
|
+
|
|
527
|
+
def transcript(self):
|
|
528
|
+
if self.speaker == "You":
|
|
529
|
+
return Padding(
|
|
530
|
+
message_row("❯", Text(self.body)),
|
|
531
|
+
0,
|
|
532
|
+
expand=True,
|
|
533
|
+
style=f"{USER_MESSAGE_COLOR} on {USER_MESSAGE_BACKGROUND}",
|
|
534
|
+
)
|
|
535
|
+
if not self.display:
|
|
536
|
+
return None
|
|
537
|
+
parts = []
|
|
538
|
+
if self.thinking_elapsed:
|
|
539
|
+
parts.append(Text(f"思考用时 {self.thinking_elapsed:.1f}s"))
|
|
540
|
+
if self.interruption_note:
|
|
541
|
+
parts.append(message_row("●", Text((self.body + "\n\n" if self.body else "") + self.interruption_note)))
|
|
542
|
+
elif self.body or not self.tool_request:
|
|
543
|
+
# A response that only calls tools has no text to show, as on screen before it was committed.
|
|
544
|
+
parts.append(message_row("●", TranscriptMarkdown(self.body or "(服务返回了空正文)")))
|
|
545
|
+
if self.completed_elapsed is not None:
|
|
546
|
+
parts.append(Text(f"\n✻ 完成 · {self.completed_elapsed:.1f}s"))
|
|
547
|
+
return Group(*parts)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def plan_text(arguments: str) -> str:
|
|
551
|
+
try:
|
|
552
|
+
plan = json.loads(arguments).get("plan")
|
|
553
|
+
except (ValueError, AttributeError):
|
|
554
|
+
return ""
|
|
555
|
+
return plan if isinstance(plan, str) else ""
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def shell_name(label: str | None) -> str:
|
|
559
|
+
if label is None:
|
|
560
|
+
return "PowerShell" if os.name == "nt" else "Shell"
|
|
561
|
+
return "PowerShell" if label == "PowerShell" else "Bash"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def agent_detail(args: dict, isolated: bool) -> str:
|
|
565
|
+
"""An agent row names the worktree only when the sub-agent runs in one."""
|
|
566
|
+
parts = [args.get("agent_type") or "fork", *(["工作树"] if isolated else []), args.get("description", "")]
|
|
567
|
+
return " ".join(" · ".join(map(str, parts)).splitlines())[:100]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def describe_call(call: ToolCall, shell: str | None = None) -> tuple[str, str, dict]:
|
|
571
|
+
"""(display name, main argument, parsed arguments) for one tool line."""
|
|
572
|
+
names = {
|
|
573
|
+
"read_file": "Read",
|
|
574
|
+
"write_file": "Write",
|
|
575
|
+
"edit_file": "Edit",
|
|
576
|
+
"memory": "Memory",
|
|
577
|
+
"execute_command": shell_name(shell),
|
|
578
|
+
"glob": "Glob",
|
|
579
|
+
"grep": "Grep",
|
|
580
|
+
"exit_plan_mode": "Plan",
|
|
581
|
+
"agent": "Agent",
|
|
582
|
+
"task_output": "TaskOutput",
|
|
583
|
+
"task_stop": "TaskStop",
|
|
584
|
+
}
|
|
585
|
+
arguments: dict = {}
|
|
586
|
+
try:
|
|
587
|
+
args = json.loads(call.arguments)
|
|
588
|
+
if isinstance(args, dict):
|
|
589
|
+
arguments = args
|
|
590
|
+
if call.name.startswith(("mcp__", "skill__")):
|
|
591
|
+
detail = json.dumps(args, ensure_ascii=False, sort_keys=True)
|
|
592
|
+
elif call.name == "memory":
|
|
593
|
+
detail = f"{args.get('action', '')} {args.get('name', '')}".strip()
|
|
594
|
+
elif call.name == "agent":
|
|
595
|
+
detail = agent_detail(args, args.get("isolation") == "worktree")
|
|
596
|
+
elif call.name in {"task_output", "task_stop"}:
|
|
597
|
+
detail = args.get("task_id", "")
|
|
598
|
+
else:
|
|
599
|
+
detail = (
|
|
600
|
+
args.get("pattern", "")
|
|
601
|
+
if call.name in {"glob", "grep"}
|
|
602
|
+
else args.get("command") or args.get("path") or ""
|
|
603
|
+
)
|
|
604
|
+
if call.name == "exit_plan_mode":
|
|
605
|
+
detail = "提交计划"
|
|
606
|
+
except (ValueError, AttributeError):
|
|
607
|
+
detail = "参数无效"
|
|
608
|
+
detail = " ".join(str(detail).splitlines())
|
|
609
|
+
return names.get(call.name, call.name), detail[:100], arguments
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
RESUMED_MESSAGES = 20
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def resumed_rows(history, shell: str | None = None, limit: int = RESUMED_MESSAGES) -> list:
|
|
616
|
+
"""The latest messages in brief, for the scrollback after a resume."""
|
|
617
|
+
shown = [m for m in history if m.role != "tool"][-limit:]
|
|
618
|
+
rows = []
|
|
619
|
+
for message in shown:
|
|
620
|
+
if message.synthetic == "compact":
|
|
621
|
+
rows.append(Text("(较早的对话已压缩)"))
|
|
622
|
+
elif message.synthetic == NOTIFICATION:
|
|
623
|
+
ids = "、".join(re.findall(r"^任务:(\S+)", message.text, re.MULTILINE))
|
|
624
|
+
rows.append(Text(f"└ 已收到后台任务通知:{ids}", style=PARAMETER_COLOR))
|
|
625
|
+
elif message.role == "user":
|
|
626
|
+
rows.append(
|
|
627
|
+
Padding(
|
|
628
|
+
message_row("❯", Text(message.shown)),
|
|
629
|
+
0,
|
|
630
|
+
expand=True,
|
|
631
|
+
style=f"{USER_MESSAGE_COLOR} on {USER_MESSAGE_BACKGROUND}",
|
|
632
|
+
)
|
|
633
|
+
)
|
|
634
|
+
else:
|
|
635
|
+
parts = [message_row("●", TranscriptMarkdown(message.text))] if message.text.strip() else []
|
|
636
|
+
for call in message.tool_calls:
|
|
637
|
+
name, detail, _ = describe_call(call, shell)
|
|
638
|
+
line = Text(" ● ")
|
|
639
|
+
line.append(name, style="bold")
|
|
640
|
+
line.append("(")
|
|
641
|
+
line.append(detail, style=PARAMETER_COLOR)
|
|
642
|
+
line.append(")")
|
|
643
|
+
parts.append(line)
|
|
644
|
+
if parts:
|
|
645
|
+
rows.append(Group(*parts))
|
|
646
|
+
return rows
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def memory_line(details: dict) -> Text:
|
|
650
|
+
"""What the memory tool did, in place of the usual result summary."""
|
|
651
|
+
text = Text("✎ ", style=TOOL_SUCCESS_COLOR)
|
|
652
|
+
name = details.get("name", "")
|
|
653
|
+
if details.get("action") == "delete":
|
|
654
|
+
text.append(f"已删除笔记:{name}")
|
|
655
|
+
else:
|
|
656
|
+
text.append(f"已记住:{name}({SCOPE_LABELS.get(details.get('scope'), '')})")
|
|
657
|
+
return text
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
class ToolEntry(Static):
|
|
661
|
+
"""Semantic foreground accents on one compact tool line; no extra panel."""
|
|
662
|
+
|
|
663
|
+
def __init__(self, event: ToolProgress, shell: str | None = None):
|
|
664
|
+
super().__init__(classes="tool-entry", markup=False)
|
|
665
|
+
self.started = monotonic()
|
|
666
|
+
self.elapsed: float | None = None
|
|
667
|
+
self.subagent = None # The task of an agent row, once it starts.
|
|
668
|
+
call = event.call
|
|
669
|
+
self.operation = call.name
|
|
670
|
+
self.operation_name, self.detail, self.arguments = describe_call(call, shell)
|
|
671
|
+
if self.operation == "agent": # The task says whether it really got a worktree (plan mode skips it).
|
|
672
|
+
self.detail = agent_detail(self.arguments, False)
|
|
673
|
+
self.label = f"● {self.operation_name}({self.detail})"
|
|
674
|
+
self.transcript = self._heading("running") + Text("\n └ 执行中…", style=OPERATION_COLOR)
|
|
675
|
+
self.update(self.transcript)
|
|
676
|
+
|
|
677
|
+
def _heading(self, state: str) -> Text:
|
|
678
|
+
marker, color = {
|
|
679
|
+
"running": ("●", OPERATION_COLOR),
|
|
680
|
+
"success": ("✓", TOOL_SUCCESS_COLOR),
|
|
681
|
+
"error": ("×", ERROR_COLOR),
|
|
682
|
+
"stopped": ("!", OPERATION_COLOR),
|
|
683
|
+
}[state]
|
|
684
|
+
if state in {"error", "stopped"}:
|
|
685
|
+
action_color = color
|
|
686
|
+
else:
|
|
687
|
+
action_color = (
|
|
688
|
+
OPERATION_COLOR
|
|
689
|
+
if self.operation in {"write_file", "edit_file", "execute_command"}
|
|
690
|
+
or self.operation.startswith(("mcp__", "skill__"))
|
|
691
|
+
else TOOL_SUCCESS_COLOR
|
|
692
|
+
)
|
|
693
|
+
text = Text()
|
|
694
|
+
text.append(marker + " ", style=color)
|
|
695
|
+
text.append(self.operation_name, style=f"bold {action_color}")
|
|
696
|
+
text.append("(")
|
|
697
|
+
text.append(self.detail, style=PARAMETER_COLOR)
|
|
698
|
+
text.append(")")
|
|
699
|
+
return text
|
|
700
|
+
|
|
701
|
+
def progress(self, task) -> None:
|
|
702
|
+
"""A sub-agent's tool calls are not rows of their own; its agent row counts them."""
|
|
703
|
+
if self.elapsed is not None:
|
|
704
|
+
return
|
|
705
|
+
if self.operation == "agent":
|
|
706
|
+
self.detail = agent_detail(self.arguments, task.isolated)
|
|
707
|
+
self.label = f"● {self.operation_name}({self.detail})"
|
|
708
|
+
place = f"工作树 {task.worktree['name']} · " if task.worktree else ""
|
|
709
|
+
if task.background:
|
|
710
|
+
line = f"后台 · {place}任务 {task.id}"
|
|
711
|
+
else:
|
|
712
|
+
line = (
|
|
713
|
+
place
|
|
714
|
+
+ f"工具调用 {task.tool_calls} 次"
|
|
715
|
+
+ (f" · 最近 {task.last_tool}" if task.last_tool else "")
|
|
716
|
+
+ f" · Token {task.tokens:,} · {task.elapsed:.0f}s · Ctrl+B 转入后台"
|
|
717
|
+
)
|
|
718
|
+
self.transcript = self._heading("running") + Text("\n └ " + line, style=OPERATION_COLOR)
|
|
719
|
+
self.update(self.transcript)
|
|
720
|
+
|
|
721
|
+
def _changes(self, result: ToolResult) -> Text | None:
|
|
722
|
+
"""Numbered diff for a successful edit or overwrite; the first lines of a new file."""
|
|
723
|
+
if result.is_error or self.operation not in EDIT_TOOLS:
|
|
724
|
+
return None
|
|
725
|
+
if result.details.get("diff"):
|
|
726
|
+
return diff_rows(result.details["diff"])
|
|
727
|
+
content = self.arguments.get("content")
|
|
728
|
+
if self.operation == "write_file" and result.details.get("created") and isinstance(content, str):
|
|
729
|
+
lines = content.splitlines()
|
|
730
|
+
text = Text()
|
|
731
|
+
for number, line in enumerate(lines[:NEW_FILE_ROWS], 1):
|
|
732
|
+
text.append(f"\n {number:>5} + {line[:160]}", style=TOOL_SUCCESS_COLOR)
|
|
733
|
+
if len(lines) > NEW_FILE_ROWS:
|
|
734
|
+
text.append(f"\n … 还有 {len(lines) - NEW_FILE_ROWS} 行未显示")
|
|
735
|
+
return text
|
|
736
|
+
return None
|
|
737
|
+
|
|
738
|
+
def complete(self, result: ToolResult, elapsed: float | None = None):
|
|
739
|
+
lines = result.output.splitlines() or ["完成,无输出。"]
|
|
740
|
+
shown = lines[:3]
|
|
741
|
+
if elapsed is not None or self.elapsed is None:
|
|
742
|
+
self.elapsed = elapsed if elapsed is not None else monotonic() - self.started
|
|
743
|
+
stopped = result.code in {"cancelled", "not_executed", "plan_blocked"}
|
|
744
|
+
state = "stopped" if stopped else "error" if result.is_error else "success"
|
|
745
|
+
if self.operation == "memory" and not result.is_error:
|
|
746
|
+
text = memory_line(result.details)
|
|
747
|
+
text.append(f" · {self.elapsed:.1f}s", style=PARAMETER_COLOR)
|
|
748
|
+
self.transcript = text
|
|
749
|
+
self.update(text)
|
|
750
|
+
return
|
|
751
|
+
text = self._heading(state)
|
|
752
|
+
text.append(f" · {self.elapsed:.1f}s\n └ ")
|
|
753
|
+
if self.operation == "agent" and result.details.get("background"):
|
|
754
|
+
text.append(f"后台 · 任务 {result.details.get('task', '')}", style=PARAMETER_COLOR)
|
|
755
|
+
self.transcript = text
|
|
756
|
+
self.update(text)
|
|
757
|
+
return
|
|
758
|
+
if self.operation == "agent" and len(lines) > 3 and lines[-1].startswith("(子 Agent"):
|
|
759
|
+
shown = [*lines[:2], lines[-1]]
|
|
760
|
+
if result.is_error:
|
|
761
|
+
label = "已取消" if result.code == "cancelled" else "未执行" if stopped else "失败"
|
|
762
|
+
text.append(label + " · ", style=f"bold {OPERATION_COLOR if stopped else ERROR_COLOR}")
|
|
763
|
+
text.append(shown[0][:160])
|
|
764
|
+
changes = self._changes(result)
|
|
765
|
+
if changes is not None:
|
|
766
|
+
text.append_text(changes)
|
|
767
|
+
else:
|
|
768
|
+
for line in shown[1:]:
|
|
769
|
+
text.append("\n " + line[:160])
|
|
770
|
+
if len(lines) > 3 or any(len(line) > 160 for line in lines[:3]) or result.details.get("truncated"):
|
|
771
|
+
text.append("\n (仅显示结果摘要)")
|
|
772
|
+
stored = persisted_note(result)
|
|
773
|
+
if stored:
|
|
774
|
+
text.append("\n " + stored, style=PARAMETER_COLOR)
|
|
775
|
+
if result.details.get("hook_modified"):
|
|
776
|
+
text.append(
|
|
777
|
+
f"\n 文件已被 Hook {result.details['hook_modified']} 修改(如格式化),模型再次编辑前会重新读取。",
|
|
778
|
+
style=PARAMETER_COLOR,
|
|
779
|
+
)
|
|
780
|
+
self.transcript = text
|
|
781
|
+
self.update(text)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class MossCodeApp(TerminalApp[None]):
|
|
785
|
+
TITLE = "MossCode"
|
|
786
|
+
SUB_TITLE = "第四章 · Agent Loop"
|
|
787
|
+
CSS = (
|
|
788
|
+
TERMINAL_CSS
|
|
789
|
+
+ """
|
|
790
|
+
Screen { background: $background; color: $foreground; padding: 1 2 0 2; overflow: hidden hidden; }
|
|
791
|
+
Screen:inline { height: auto; border: none; }
|
|
792
|
+
#banner { height: auto; margin-bottom: 1; }
|
|
793
|
+
#chat { height: auto; max-height: 100vh; scrollbar-size: 1 0; }
|
|
794
|
+
.entry { height: auto; padding: 0; margin-bottom: 1; background: $background; }
|
|
795
|
+
.message-row { height: auto; }
|
|
796
|
+
.user-entry .message-row, .user-entry .message-row > Static {
|
|
797
|
+
background: USER_MESSAGE_BACKGROUND !important; color: USER_MESSAGE_COLOR !important;
|
|
798
|
+
}
|
|
799
|
+
.message-marker { width: 2; height: 1; text-style: bold; }
|
|
800
|
+
.user-message, .response-content { width: 1fr; height: auto; }
|
|
801
|
+
.raw, .thinking, .answer { height: auto; padding: 0; margin: 0; background: $background; }
|
|
802
|
+
.thinking-label, .thinking { height: auto; padding: 0 0 0 2; margin-bottom: 1; }
|
|
803
|
+
.turn-summary { height: auto; margin-top: 1; }
|
|
804
|
+
.answer MarkdownHeader { content-align: left middle; margin: 1 0; }
|
|
805
|
+
.answer > MarkdownBlock:last-of-type { margin-bottom: 0; }
|
|
806
|
+
.answer MarkdownBlock > .code_inline { color: PARAMETER_COLOR !important; text-style: bold; }
|
|
807
|
+
#status { height: auto; margin-top: 0; color: $text-muted; }
|
|
808
|
+
#queue { height: auto; margin-top: 1; padding: 0 1; }
|
|
809
|
+
#input-row { height: auto; margin-top: 1; padding: 0 1; border-top: solid $foreground 35%; border-bottom: solid $foreground 35%; }
|
|
810
|
+
#input-prefix { width: 2; height: 1; color: $primary; }
|
|
811
|
+
#prompt { width: 1fr; height: auto; max-height: 8; border: none; padding: 0; background: $background; }
|
|
812
|
+
#prompt:focus { border: none; background-tint: transparent; }
|
|
813
|
+
#command-hints { height: auto; max-height: 7; border: none; padding: 0 1; }
|
|
814
|
+
#command-hints > .option-list--option-highlighted { color: COMMAND_HIGHLIGHT_COLOR !important; text-style: bold; }
|
|
815
|
+
#command-help { height: 1; color: $text-muted; padding: 0 1; }
|
|
816
|
+
#model-panel { width: 1fr; height: auto; padding: 0 1; border-top: solid $foreground; border-bottom: solid $foreground; }
|
|
817
|
+
#model-title, #model-help { height: 1; }
|
|
818
|
+
#models { height: auto; max-height: 8; border: none; padding: 0; }
|
|
819
|
+
#models > .option-list--option-highlighted { color: COMMAND_HIGHLIGHT_COLOR !important; text-style: bold; }
|
|
820
|
+
#approval-panel { height: auto; max-height: 15; padding: 0 1; border-top: solid $foreground; }
|
|
821
|
+
#approval-detail { height: auto; max-height: 20; }
|
|
822
|
+
#approval-options { height: 4; border: none; padding: 0; }
|
|
823
|
+
#approval-options > .option-list--option-highlighted { color: COMMAND_HIGHLIGHT_COLOR !important; text-style: bold; }
|
|
824
|
+
#approval-help { height: auto; }
|
|
825
|
+
.local-note { height: auto; margin: 0 0 1 2; color: $text-muted; }
|
|
826
|
+
.resumed-entry { height: auto; margin-bottom: 1; }
|
|
827
|
+
.tool-entry { height: auto; margin-bottom: 1; }
|
|
828
|
+
#member-panel { height: auto; padding: 0 1; }
|
|
829
|
+
#member-compact { height: auto; }
|
|
830
|
+
#member-list { height: auto; max-height: 8; border: none; padding: 0; }
|
|
831
|
+
#member-list > .option-list--option-highlighted { color: COMMAND_HIGHLIGHT_COLOR !important; text-style: bold; }
|
|
832
|
+
#member-help { height: 1; color: $text-muted; }
|
|
833
|
+
#member-view { height: auto; padding: 0 1; }
|
|
834
|
+
#member-head { height: 1; }
|
|
835
|
+
#member-log { height: 1fr; scrollbar-size: 1 0; background: $background; }
|
|
836
|
+
#member-running { height: auto; max-height: 6; }
|
|
837
|
+
#member-view-help { height: 1; }
|
|
838
|
+
OptionList { background: $background; }
|
|
839
|
+
""".replace("COMMAND_HIGHLIGHT_COLOR", COMMAND_HIGHLIGHT_COLOR)
|
|
840
|
+
.replace("PARAMETER_COLOR", PARAMETER_COLOR)
|
|
841
|
+
.replace("USER_MESSAGE_BACKGROUND", USER_MESSAGE_BACKGROUND)
|
|
842
|
+
.replace("USER_MESSAGE_COLOR", USER_MESSAGE_COLOR)
|
|
843
|
+
)
|
|
844
|
+
BINDINGS = [
|
|
845
|
+
Binding("escape", "cancel", "取消生成", priority=True, show=False),
|
|
846
|
+
Binding("ctrl+c", "cancel_task", "取消任务", priority=True, show=False),
|
|
847
|
+
Binding("ctrl+q", "quit", "退出", priority=True, show=False),
|
|
848
|
+
Binding("up", "move_hint(-1)", priority=True, show=False),
|
|
849
|
+
Binding("down", "move_hint(1)", priority=True, show=False),
|
|
850
|
+
Binding("tab", "complete_hint", priority=True, show=False),
|
|
851
|
+
Binding("shift+tab", "cycle_mode", priority=True, show=False),
|
|
852
|
+
Binding("ctrl+b", "background", priority=True, show=False),
|
|
853
|
+
Binding("ctrl+t", "members", priority=True, show=False),
|
|
854
|
+
Binding("enter", "complete_hint(False)", priority=True, show=False),
|
|
855
|
+
]
|
|
856
|
+
|
|
857
|
+
def __init__(
|
|
858
|
+
self,
|
|
859
|
+
conversation: Conversation,
|
|
860
|
+
provider_name: str,
|
|
861
|
+
*,
|
|
862
|
+
settings: Settings | None = None,
|
|
863
|
+
model: str = "",
|
|
864
|
+
provider_factory=None,
|
|
865
|
+
resume: str | None = None,
|
|
866
|
+
commands: CommandRegistry | None = None,
|
|
867
|
+
initial_prompt: str | None = None,
|
|
868
|
+
) -> None:
|
|
869
|
+
super().__init__()
|
|
870
|
+
self.conversation = conversation
|
|
871
|
+
# --resume ID / --continue restore at startup; "" opens the session list once started.
|
|
872
|
+
self._resume = resume
|
|
873
|
+
# `moss "..."` sends this once startup is done, as if typed.
|
|
874
|
+
self._initial_prompt = initial_prompt
|
|
875
|
+
self._panel_rows = 0
|
|
876
|
+
self._pick = None # The open picker's on_pick (CommandUI.choose).
|
|
877
|
+
self._on_cancel = None # Its on_cancel, run by Esc.
|
|
878
|
+
self._hooks_confirmed = False # Asked once at startup, before session_start.
|
|
879
|
+
# A table of this run's own: skill commands are added here and replaced by /skills reload.
|
|
880
|
+
self.commands = commands if commands is not None else BUILTIN.with_skills(conversation.skills)[0]
|
|
881
|
+
self.provider_name = provider_name
|
|
882
|
+
self.settings, self.model = settings, model
|
|
883
|
+
if provider_factory is None:
|
|
884
|
+
from .providers import create_provider as provider_factory
|
|
885
|
+
self.provider_factory = provider_factory
|
|
886
|
+
self._reply_task: asyncio.Task[None] | None = None
|
|
887
|
+
self._generating = False
|
|
888
|
+
self._active_reply: ChatEntry | None = None
|
|
889
|
+
self._closing = False
|
|
890
|
+
self.started = 0.0
|
|
891
|
+
self._iteration = 0
|
|
892
|
+
self._iteration_limit = 25
|
|
893
|
+
self._usage = conversation.total_usage
|
|
894
|
+
self._status_note = ""
|
|
895
|
+
self._mcp_notices: list[str] = [] # MCP servers that settled while a task was running.
|
|
896
|
+
self._retry_notice = "" # Held until the next event so the ticker does not hide it.
|
|
897
|
+
self._hint_until = 0.0 # Short notes shown over the running status until this time.
|
|
898
|
+
self._hint_choices: list[Hint] = []
|
|
899
|
+
self._dismissed_prompt: str | None = None
|
|
900
|
+
self._scrollback_lock = asyncio.Lock()
|
|
901
|
+
self._history: list = [] # Everything committed to scrollback, re-rendered after a width change.
|
|
902
|
+
self._width: int | None = None
|
|
903
|
+
self._redraw_timer = None
|
|
904
|
+
self._approval_request: ApprovalRequest | None = None
|
|
905
|
+
self._approval_queue: list[ApprovalRequest] = [] # Shown one at a time, in arrival order.
|
|
906
|
+
self._startup_task = None
|
|
907
|
+
self._compacting: float | None = None # Start time while a compaction runs.
|
|
908
|
+
self._compact_row: CompactRow | None = None
|
|
909
|
+
self._context_share: tuple[int, bool] | None = None # (percent of the effective window, near threshold)
|
|
910
|
+
self._agent_rows: dict[str, ToolEntry] = {} # Agent tool rows by call id.
|
|
911
|
+
self._notify_waiting = False # A notification turn waits for the running task.
|
|
912
|
+
self._announce = Announcer(self._task_note)
|
|
913
|
+
# The member panel's rows, read off the loop, and the member view.
|
|
914
|
+
self._member_rows: list[MemberRow] = []
|
|
915
|
+
self._members_task: asyncio.Task | None = None
|
|
916
|
+
self._members_again = False
|
|
917
|
+
self._member_ticks = 0
|
|
918
|
+
self._stop_armed: tuple[str, float] | None = None # The member the first x picked, until when.
|
|
919
|
+
self._view_lock = asyncio.Lock()
|
|
920
|
+
|
|
921
|
+
def compose(self) -> ComposeResult:
|
|
922
|
+
with VerticalScroll(id="chat"):
|
|
923
|
+
yield SessionInfo(startup_banner(self.provider_name), id="banner")
|
|
924
|
+
view = MemberView(id="member-view")
|
|
925
|
+
view.display = False
|
|
926
|
+
yield view
|
|
927
|
+
queue = Static("", id="queue", markup=False)
|
|
928
|
+
queue.display = False
|
|
929
|
+
yield queue
|
|
930
|
+
with Horizontal(id="input-row"):
|
|
931
|
+
yield Static("❯", id="input-prefix")
|
|
932
|
+
prompt = PromptArea(placeholder="输入消息,或 / 查看命令;Shift+Enter 换行", id="prompt")
|
|
933
|
+
prompt.take_queued = self._take_back_queued
|
|
934
|
+
yield prompt
|
|
935
|
+
panel = ModelPanel(id="model-panel")
|
|
936
|
+
panel.display = False
|
|
937
|
+
yield panel
|
|
938
|
+
with Vertical(id="approval-panel") as approval:
|
|
939
|
+
approval.display = False
|
|
940
|
+
yield Static("", id="approval-detail", markup=False)
|
|
941
|
+
yield KeyboardOptions(*(CHOICE_LABELS[choice] for choice in CHOICES), id="approval-options")
|
|
942
|
+
yield Static("↑ / ↓ 选择 · Enter 确认 · Esc 拒绝本次 · Ctrl+C 取消任务", id="approval-help", markup=False)
|
|
943
|
+
hints = KeyboardOptions(id="command-hints")
|
|
944
|
+
hints.can_focus = False
|
|
945
|
+
hints.display = False
|
|
946
|
+
yield hints
|
|
947
|
+
help_line = Static("", id="command-help", markup=False)
|
|
948
|
+
help_line.display = False
|
|
949
|
+
yield help_line
|
|
950
|
+
members = MemberPanel(id="member-panel")
|
|
951
|
+
members.display = False
|
|
952
|
+
yield members
|
|
953
|
+
yield SessionInfo(Text(""), id="status")
|
|
954
|
+
|
|
955
|
+
def on_mount(self) -> None:
|
|
956
|
+
super().on_mount()
|
|
957
|
+
if self.conversation.registry:
|
|
958
|
+
self.conversation.registry.permissions.interactive = True
|
|
959
|
+
self.conversation.on_memory = self._on_memory
|
|
960
|
+
self.conversation.on_tasks = self._schedule_notify
|
|
961
|
+
self.conversation.tasks.on_change = self._on_task_change
|
|
962
|
+
teams = getattr(self.conversation, "teams", None)
|
|
963
|
+
if teams is not None: # Members ask and report while the lead runs or waits.
|
|
964
|
+
teams.on_approval = self._member_approval
|
|
965
|
+
teams.on_note = lambda text: self._closing or self.call_later(self._local_note, "└ " + text)
|
|
966
|
+
teams.on_change = lambda: self._closing or self.call_later(self._members_changed)
|
|
967
|
+
teams.on_withdraw = lambda request_id: self._closing or self.call_later(self._withdraw_approval, request_id)
|
|
968
|
+
teams.on_commit = self._member_committed
|
|
969
|
+
if self.conversation.context_root is not None:
|
|
970
|
+
self.conversation.start_worktree_cleanup(
|
|
971
|
+
lambda report: self.conversation._hook_notice(clean_text(report, automatic=True))
|
|
972
|
+
)
|
|
973
|
+
self.query_one("#prompt", PromptArea).focus()
|
|
974
|
+
self._width = self.size.width
|
|
975
|
+
self._status("")
|
|
976
|
+
self.set_interval(0.2, self._tick)
|
|
977
|
+
self.set_interval(0.5, self._member_tick)
|
|
978
|
+
self.call_later(self._refresh_layout)
|
|
979
|
+
self.call_after_refresh(self._commit_ready_transcript)
|
|
980
|
+
# The prompt stays usable: a first task waits only for project context, and MCP
|
|
981
|
+
# servers join in the background as each one becomes ready.
|
|
982
|
+
if self._resume:
|
|
983
|
+
# Restoring loads the project context itself; MCP and the other notices follow.
|
|
984
|
+
self._start_resume(self._resume, startup=True)
|
|
985
|
+
elif self.conversation.mcp_manager is not None or self.conversation.context_root is not None:
|
|
986
|
+
self._startup_task = asyncio.create_task(self._initialize())
|
|
987
|
+
elif self._resume == "":
|
|
988
|
+
self.call_later(open_sessions, self._command_context())
|
|
989
|
+
elif self._initial_prompt:
|
|
990
|
+
self.call_later(self._send_initial)
|
|
991
|
+
|
|
992
|
+
async def _send_initial(self) -> None:
|
|
993
|
+
text, self._initial_prompt = self._initial_prompt, None
|
|
994
|
+
if text and text.strip() and not self._closing:
|
|
995
|
+
field = self.query_one("#prompt", PromptArea)
|
|
996
|
+
await self.on_prompt_area_submitted(PromptArea.Submitted(field, text))
|
|
997
|
+
|
|
998
|
+
async def _confirm_hooks(self) -> None:
|
|
999
|
+
"""Untrusted project hook files are confirmed before the first session_start."""
|
|
1000
|
+
if self._hooks_confirmed:
|
|
1001
|
+
return
|
|
1002
|
+
self._hooks_confirmed = True
|
|
1003
|
+
done = asyncio.get_running_loop().create_future()
|
|
1004
|
+
|
|
1005
|
+
async def finished() -> None:
|
|
1006
|
+
if not done.done():
|
|
1007
|
+
done.set_result(None)
|
|
1008
|
+
|
|
1009
|
+
await offer_trust(self, self.conversation.hooks, finished)
|
|
1010
|
+
await done
|
|
1011
|
+
|
|
1012
|
+
async def _initialize(self):
|
|
1013
|
+
try:
|
|
1014
|
+
await self._confirm_hooks()
|
|
1015
|
+
for notice in await self.conversation.start(wait_mcp=False, on_mcp=self._on_mcp_update):
|
|
1016
|
+
await self._local_note("└ " + notice)
|
|
1017
|
+
await self._commit_ready_transcript()
|
|
1018
|
+
except Exception:
|
|
1019
|
+
await self._local_note("└ 启动初始化失败;请检查配置后重新启动。")
|
|
1020
|
+
finally:
|
|
1021
|
+
if not self._closing and not self._generating:
|
|
1022
|
+
self._status("")
|
|
1023
|
+
if self._initial_prompt and not self._closing:
|
|
1024
|
+
await self._send_initial()
|
|
1025
|
+
if self._resume == "" and not self._closing:
|
|
1026
|
+
self._resume = None
|
|
1027
|
+
await open_sessions(self._command_context())
|
|
1028
|
+
|
|
1029
|
+
def _on_mcp_update(self, status) -> None:
|
|
1030
|
+
if self._closing:
|
|
1031
|
+
return
|
|
1032
|
+
if status.state == "ready":
|
|
1033
|
+
pass # Like Claude Code: only problems are announced; /mcp lists connected servers.
|
|
1034
|
+
elif self._task_running:
|
|
1035
|
+
# Keep the running reply contiguous; report once the task ends.
|
|
1036
|
+
self._mcp_notices.append(status.notice())
|
|
1037
|
+
else:
|
|
1038
|
+
self.call_later(self._local_note, "└ " + status.notice())
|
|
1039
|
+
if not self._generating:
|
|
1040
|
+
self._status(self._status_note)
|
|
1041
|
+
|
|
1042
|
+
def _on_memory(self, event) -> None:
|
|
1043
|
+
"""From the background extractor; a running task's output stays contiguous, so wait for it."""
|
|
1044
|
+
if self._closing:
|
|
1045
|
+
return
|
|
1046
|
+
if isinstance(event, MemoryUpdated):
|
|
1047
|
+
text, style = "✎ 已更新记忆:" + "、".join(event.names), PARAMETER_COLOR
|
|
1048
|
+
else:
|
|
1049
|
+
text, style = "! " + event.text, OPERATION_COLOR
|
|
1050
|
+
if self._task_running:
|
|
1051
|
+
self._reply_task.add_done_callback(
|
|
1052
|
+
lambda _: self._closing or self.call_later(self._local_note, text, style)
|
|
1053
|
+
)
|
|
1054
|
+
else:
|
|
1055
|
+
self.call_later(self._local_note, text, style)
|
|
1056
|
+
self._usage = self.conversation.total_usage # Extraction requests are a real cost.
|
|
1057
|
+
if not self._generating:
|
|
1058
|
+
self._status(self._status_note)
|
|
1059
|
+
|
|
1060
|
+
# Sub-agents -------------------------------------------------------------------
|
|
1061
|
+
|
|
1062
|
+
def _on_task_change(self, task) -> None:
|
|
1063
|
+
if self._closing:
|
|
1064
|
+
return
|
|
1065
|
+
self._announce(task)
|
|
1066
|
+
row = self._agent_rows.get(task.call_id) if task.call_id else None
|
|
1067
|
+
if row is not None:
|
|
1068
|
+
row.subagent = task
|
|
1069
|
+
row.progress(task)
|
|
1070
|
+
if not self._generating:
|
|
1071
|
+
self._status(self._status_note)
|
|
1072
|
+
|
|
1073
|
+
def _task_note(self, text: str) -> None:
|
|
1074
|
+
"""Like memory notes, a running task's output stays contiguous."""
|
|
1075
|
+
if self._task_running:
|
|
1076
|
+
self._reply_task.add_done_callback(
|
|
1077
|
+
lambda _: self._closing or self.call_later(self._local_note, "└ " + text)
|
|
1078
|
+
)
|
|
1079
|
+
else:
|
|
1080
|
+
self.call_later(self._local_note, "└ " + text)
|
|
1081
|
+
|
|
1082
|
+
def _schedule_notify(self) -> None:
|
|
1083
|
+
"""Background results wait while the main agent is busy, then start a turn of their own."""
|
|
1084
|
+
if self._closing:
|
|
1085
|
+
return
|
|
1086
|
+
if not self._task_running:
|
|
1087
|
+
self.call_later(self._start_notify)
|
|
1088
|
+
elif not self._notify_waiting:
|
|
1089
|
+
self._notify_waiting = True
|
|
1090
|
+
self._reply_task.add_done_callback(self._notify_after)
|
|
1091
|
+
|
|
1092
|
+
def _notify_after(self, _) -> None:
|
|
1093
|
+
self._notify_waiting = False
|
|
1094
|
+
if not self._closing:
|
|
1095
|
+
self.call_later(self._start_notify)
|
|
1096
|
+
|
|
1097
|
+
async def _start_notify(self) -> None:
|
|
1098
|
+
if self._closing or not self.conversation.notifications:
|
|
1099
|
+
return
|
|
1100
|
+
if self._task_running:
|
|
1101
|
+
self._schedule_notify()
|
|
1102
|
+
return
|
|
1103
|
+
await self._start_request("", show_user=False, notify=True)
|
|
1104
|
+
|
|
1105
|
+
def action_background(self) -> None:
|
|
1106
|
+
"""Ctrl+B moves every foreground sub-agent to the background; Esc still cancels."""
|
|
1107
|
+
count = self.conversation.tasks.detach_foreground()
|
|
1108
|
+
self._hint_until = monotonic() + 2
|
|
1109
|
+
self._status(f"已把 {count} 个子 Agent 转入后台" if count else "没有在前台运行的子 Agent")
|
|
1110
|
+
|
|
1111
|
+
async def action_quit(self) -> None:
|
|
1112
|
+
async def leave() -> None:
|
|
1113
|
+
self.exit()
|
|
1114
|
+
|
|
1115
|
+
await confirm_tasks(self, self.conversation, leave)
|
|
1116
|
+
|
|
1117
|
+
def on_resize(self, event: events.Resize) -> None:
|
|
1118
|
+
self.call_later(self._refresh_layout)
|
|
1119
|
+
width, self._width = self._width, event.size.width
|
|
1120
|
+
if self.is_inline and width is not None and width != event.size.width:
|
|
1121
|
+
# A drag sends many resizes; repaint once the width settles.
|
|
1122
|
+
if self._redraw_timer is not None:
|
|
1123
|
+
self._redraw_timer.stop()
|
|
1124
|
+
self._redraw_timer = self.set_timer(0.2, self._redraw_after_resize)
|
|
1125
|
+
|
|
1126
|
+
async def _redraw_after_resize(self) -> None:
|
|
1127
|
+
self._redraw_timer = None
|
|
1128
|
+
if self._closing:
|
|
1129
|
+
return
|
|
1130
|
+
async with self._scrollback_lock:
|
|
1131
|
+
with self.batch_update():
|
|
1132
|
+
redraw_transcript(self, self._history)
|
|
1133
|
+
|
|
1134
|
+
def on_session_info_resized(self) -> None:
|
|
1135
|
+
self._fit_chat()
|
|
1136
|
+
|
|
1137
|
+
def _refresh_layout(self) -> None:
|
|
1138
|
+
if self.query("#banner"):
|
|
1139
|
+
self.query_one("#banner", Static).update(startup_banner(self.provider_name))
|
|
1140
|
+
self._status(self._status_note)
|
|
1141
|
+
self._fit_chat()
|
|
1142
|
+
if self._hint_choices:
|
|
1143
|
+
self._refresh_hints(self.query_one("#prompt", PromptArea).value)
|
|
1144
|
+
|
|
1145
|
+
def _fit_chat(self) -> None:
|
|
1146
|
+
if not self.query("#chat") or not self.query("#status"):
|
|
1147
|
+
return
|
|
1148
|
+
# Reserve the actual controls, rather than a percentage or spare rows.
|
|
1149
|
+
input_row = self.query_one("#input-row")
|
|
1150
|
+
# The prompt grows with its text; its region may still hold the previous height.
|
|
1151
|
+
prompt_rows = min(8, max(1, self.query_one("#prompt", PromptArea).wrapped_document.height))
|
|
1152
|
+
reserved = self.screen.styles.gutter.height + max(1, self.query_one("#status").outer_size.height)
|
|
1153
|
+
if input_row.display: # Hidden while a member view is open.
|
|
1154
|
+
reserved += max(2 + prompt_rows, input_row.region.height) + input_row.styles.margin.height
|
|
1155
|
+
members = self.query_one("#member-panel", MemberPanel)
|
|
1156
|
+
if members.display:
|
|
1157
|
+
reserved += members.lines
|
|
1158
|
+
queue = self.query_one("#queue")
|
|
1159
|
+
if queue.display:
|
|
1160
|
+
reserved += 1 + min(4, len(self.conversation.queued))
|
|
1161
|
+
if self.query_one("#model-panel").display:
|
|
1162
|
+
reserved += 4 + min(8, self._panel_rows)
|
|
1163
|
+
if self.query_one("#approval-panel").display:
|
|
1164
|
+
# Keep the choices and status reachable in a short terminal; only
|
|
1165
|
+
# the preview shrinks. Completed history already lives in scrollback.
|
|
1166
|
+
# Border, one row per choice and up to two rows of help.
|
|
1167
|
+
fixed = 3 + (len(self._approval_request.choices) if self._approval_request else 4)
|
|
1168
|
+
limit = max(fixed, self.size.height - reserved - 1)
|
|
1169
|
+
self.query_one("#approval-panel").styles.max_height = limit
|
|
1170
|
+
self.query_one("#approval-detail").styles.max_height = min(20, max(1, limit - fixed))
|
|
1171
|
+
# Its current size may predate the new preview; reserve what it may grow to.
|
|
1172
|
+
reserved += limit
|
|
1173
|
+
hints = self.query_one("#command-hints")
|
|
1174
|
+
hint_limit = min(8, max(1, self.size.height - reserved - 2))
|
|
1175
|
+
if hints.styles.max_height is None or hints.styles.max_height.value != hint_limit:
|
|
1176
|
+
hints.styles.max_height = hint_limit
|
|
1177
|
+
# Reserve hints before layout: their old size may still be zero on show.
|
|
1178
|
+
hint_rows = min(len(self._hint_choices), hint_limit)
|
|
1179
|
+
hints.styles.height = max(1, hint_rows)
|
|
1180
|
+
if hints.display:
|
|
1181
|
+
reserved += hint_rows + 1
|
|
1182
|
+
chat = self.query_one("#chat")
|
|
1183
|
+
available = max(1, self.size.height - reserved)
|
|
1184
|
+
view = self.query_one("#member-view", MemberView)
|
|
1185
|
+
if view.display: # The whole height but the status line (and a card).
|
|
1186
|
+
height = max(3, int(available * VIEW_SHARE))
|
|
1187
|
+
if view.styles.height is None or view.styles.height.value != height:
|
|
1188
|
+
view.styles.height = height
|
|
1189
|
+
return
|
|
1190
|
+
if chat.styles.max_height is None or chat.styles.max_height.value != available:
|
|
1191
|
+
chat.styles.max_height = available
|
|
1192
|
+
|
|
1193
|
+
async def _commit_ready_transcript(self) -> None:
|
|
1194
|
+
if not self.is_inline or self._closing:
|
|
1195
|
+
return
|
|
1196
|
+
async with self._scrollback_lock:
|
|
1197
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1198
|
+
ready, renderables = [], []
|
|
1199
|
+
for widget in list(chat.children):
|
|
1200
|
+
if isinstance(widget, ChatEntry):
|
|
1201
|
+
if widget.speaker != "You" and widget.phase not in {"complete", "tools", "interrupted"}:
|
|
1202
|
+
break
|
|
1203
|
+
rendered = widget.transcript()
|
|
1204
|
+
elif isinstance(widget, ToolEntry):
|
|
1205
|
+
if widget.elapsed is None:
|
|
1206
|
+
break
|
|
1207
|
+
rendered = widget.transcript
|
|
1208
|
+
elif isinstance(widget, CompactRow):
|
|
1209
|
+
if widget.transcript is None:
|
|
1210
|
+
break
|
|
1211
|
+
rendered = widget.transcript
|
|
1212
|
+
elif widget.id == "banner":
|
|
1213
|
+
rendered = startup_banner(self.provider_name)
|
|
1214
|
+
elif hasattr(widget, "transcript"):
|
|
1215
|
+
rendered = widget.transcript
|
|
1216
|
+
else:
|
|
1217
|
+
break
|
|
1218
|
+
ready.append(widget)
|
|
1219
|
+
if rendered is not None:
|
|
1220
|
+
renderables.append(rendered)
|
|
1221
|
+
if ready:
|
|
1222
|
+
with self.batch_update():
|
|
1223
|
+
if renderables:
|
|
1224
|
+
commit_transcript(self, renderables)
|
|
1225
|
+
self._history.extend(renderables)
|
|
1226
|
+
await chat.remove_children(ready)
|
|
1227
|
+
self._fit_chat()
|
|
1228
|
+
|
|
1229
|
+
def _status(self, note: str) -> None:
|
|
1230
|
+
self._status_note = note
|
|
1231
|
+
text = Text()
|
|
1232
|
+
registry = self.conversation.registry
|
|
1233
|
+
if registry is None:
|
|
1234
|
+
text.append("未启用工具", style="bold")
|
|
1235
|
+
else:
|
|
1236
|
+
mode = registry.permissions.mode
|
|
1237
|
+
color = {"acceptEdits": TOOL_SUCCESS_COLOR, "plan": OPERATION_COLOR, "bypassPermissions": ERROR_COLOR}.get(
|
|
1238
|
+
mode
|
|
1239
|
+
)
|
|
1240
|
+
text.append(MODE_BADGES[mode], style=f"bold {color}" if color else "bold")
|
|
1241
|
+
if getattr(self.conversation, "coordinator", False):
|
|
1242
|
+
text.append(" " + COORDINATOR_BADGE, style=f"bold {OPERATION_COLOR}")
|
|
1243
|
+
if not self._generating and self.size.width >= 80:
|
|
1244
|
+
text.append(" (Shift+Tab 切换)")
|
|
1245
|
+
if self.model:
|
|
1246
|
+
text.append(" · " + self.model)
|
|
1247
|
+
if note:
|
|
1248
|
+
stage, separator, rest = note.partition(" · ")
|
|
1249
|
+
stage_color = (
|
|
1250
|
+
ERROR_COLOR
|
|
1251
|
+
if stage.startswith("失败")
|
|
1252
|
+
else OPERATION_COLOR
|
|
1253
|
+
if self._generating or stage.startswith(("已取消", "已停止"))
|
|
1254
|
+
else None
|
|
1255
|
+
)
|
|
1256
|
+
text.append(" · ")
|
|
1257
|
+
text.append(stage, style=stage_color)
|
|
1258
|
+
if separator:
|
|
1259
|
+
text.append(separator + rest)
|
|
1260
|
+
manager = self.conversation.mcp_manager
|
|
1261
|
+
if manager is not None and manager.pending:
|
|
1262
|
+
total = len(manager.connections)
|
|
1263
|
+
text.append(f" · MCP 连接中 {total - manager.pending}/{total}", style=OPERATION_COLOR)
|
|
1264
|
+
queued = len(self.conversation.queued)
|
|
1265
|
+
if queued:
|
|
1266
|
+
text.append(f" · 已排队 {queued} 条", style=OPERATION_COLOR)
|
|
1267
|
+
tasks = self.conversation.tasks
|
|
1268
|
+
background = len(tasks.background())
|
|
1269
|
+
if background:
|
|
1270
|
+
text.append(f" · 后台任务 {background}", style=OPERATION_COLOR)
|
|
1271
|
+
if any(not task.background and task.origin == "agent" for task in tasks.running()):
|
|
1272
|
+
text.append(" · Ctrl+B 转入后台", style=OPERATION_COLOR)
|
|
1273
|
+
teams = getattr(self.conversation, "teams", None)
|
|
1274
|
+
if teams is not None and teams.current is not None:
|
|
1275
|
+
running, idle = teams.counts()
|
|
1276
|
+
text.append(f" · 团队 {teams.current.name} 运行 {running} / 空闲 {idle}")
|
|
1277
|
+
asking = sum(self._asking().values()) # Pane members' too.
|
|
1278
|
+
if asking:
|
|
1279
|
+
text.append(f" · 成员审批 {asking}", style=OPERATION_COLOR)
|
|
1280
|
+
if (
|
|
1281
|
+
self.query("#member-panel")
|
|
1282
|
+
and self.query_one("#member-panel").display
|
|
1283
|
+
and not self.query_one("#member-panel", MemberPanel).expanded
|
|
1284
|
+
):
|
|
1285
|
+
text.append(" · Ctrl+T 成员")
|
|
1286
|
+
if self._usage.requests:
|
|
1287
|
+
|
|
1288
|
+
def tokens(value, missing):
|
|
1289
|
+
if missing == self._usage.requests:
|
|
1290
|
+
return "—"
|
|
1291
|
+
return ("≥" if missing else "") + str(value)
|
|
1292
|
+
|
|
1293
|
+
if self._context_share is not None:
|
|
1294
|
+
percent, near = self._context_share
|
|
1295
|
+
text.append(f" · 上下文 约 {percent}%", style=OPERATION_COLOR if near else None)
|
|
1296
|
+
text.append(
|
|
1297
|
+
f" · Token 入 {tokens(self._usage.input_tokens, self._usage.missing_input)} / 出 {tokens(self._usage.output_tokens, self._usage.missing_output)}"
|
|
1298
|
+
)
|
|
1299
|
+
text.append(" · " + cache_summary(self._usage))
|
|
1300
|
+
status = self.query("#status")
|
|
1301
|
+
if not status: # Timers and MCP callbacks can fire while the screen is torn down.
|
|
1302
|
+
return
|
|
1303
|
+
status.first(Static).update(text)
|
|
1304
|
+
self.call_later(self._fit_chat)
|
|
1305
|
+
|
|
1306
|
+
def _refresh_context(self) -> None:
|
|
1307
|
+
"""Cached: the ticker repaints every 0.2s, the estimate changes only with the history."""
|
|
1308
|
+
estimate = self.conversation.context_estimate() if self._usage.requests else None
|
|
1309
|
+
if estimate is None:
|
|
1310
|
+
self._context_share = None
|
|
1311
|
+
return
|
|
1312
|
+
budget = self.conversation.budget
|
|
1313
|
+
self._context_share = (round(100 * estimate / budget.effective), estimate >= 0.8 * budget.auto_threshold)
|
|
1314
|
+
|
|
1315
|
+
def _tick(self) -> None:
|
|
1316
|
+
if self._generating and self._compacting is not None:
|
|
1317
|
+
if self._compact_row is not None:
|
|
1318
|
+
self._compact_row.tick()
|
|
1319
|
+
self._status(f"压缩上下文中 · {monotonic() - self._compacting:.1f}s · Esc 取消")
|
|
1320
|
+
return
|
|
1321
|
+
if self._generating and self._active_reply is not None:
|
|
1322
|
+
if self._approval_request is not None:
|
|
1323
|
+
self._status("等待授权 · Esc 拒绝本次 · Ctrl+C 取消任务")
|
|
1324
|
+
return
|
|
1325
|
+
if self._retry_notice:
|
|
1326
|
+
self._status(self._retry_notice)
|
|
1327
|
+
return
|
|
1328
|
+
self._active_reply.refresh_timer()
|
|
1329
|
+
for row in self._agent_rows.values():
|
|
1330
|
+
if row.subagent is not None:
|
|
1331
|
+
row.progress(row.subagent)
|
|
1332
|
+
if monotonic() < self._hint_until:
|
|
1333
|
+
return
|
|
1334
|
+
label = {"waiting": "连接中", "thinking": "思考中", "streaming": "输出中", "tools": "执行工具中"}.get(
|
|
1335
|
+
self._active_reply.phase, "处理中"
|
|
1336
|
+
)
|
|
1337
|
+
self._status(
|
|
1338
|
+
f"{label} · {self._iteration}/{self._iteration_limit} 轮 · {monotonic() - self.started:.1f}s · Esc 取消"
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
|
1342
|
+
if event.text_area.id == "prompt":
|
|
1343
|
+
value = event.text_area.text
|
|
1344
|
+
# Esc suppresses this edit only, not a matching command typed later.
|
|
1345
|
+
if value != self._dismissed_prompt:
|
|
1346
|
+
self._dismissed_prompt = None
|
|
1347
|
+
self._refresh_hints(value)
|
|
1348
|
+
|
|
1349
|
+
def _take_back_queued(self) -> tuple[str, ...]:
|
|
1350
|
+
"""↑ in the prompt: pull queued messages back for editing without interrupting the task."""
|
|
1351
|
+
if not self._task_running:
|
|
1352
|
+
return ()
|
|
1353
|
+
queued = self.conversation.take_queued()
|
|
1354
|
+
if queued:
|
|
1355
|
+
self._refresh_queue()
|
|
1356
|
+
return queued
|
|
1357
|
+
|
|
1358
|
+
def _refresh_queue(self) -> None:
|
|
1359
|
+
queue = self.query_one("#queue", Static)
|
|
1360
|
+
queued = self.conversation.queued
|
|
1361
|
+
queue.display = bool(queued)
|
|
1362
|
+
if queued:
|
|
1363
|
+
text = Text()
|
|
1364
|
+
for index, item in enumerate(queued[:4]):
|
|
1365
|
+
first = item.strip().splitlines()[0] if item.strip() else ""
|
|
1366
|
+
more = "…" if len(first) > 80 or "\n" in item.strip() else ""
|
|
1367
|
+
text.append(("\n" if index else "") + "↳ 排队:" + first[:80] + more, style=OPERATION_COLOR)
|
|
1368
|
+
if len(queued) > 4:
|
|
1369
|
+
text.append(f"\n↳ 另有 {len(queued) - 4} 条", style=OPERATION_COLOR)
|
|
1370
|
+
text.append("\n 将在本轮工具执行后发送给模型 · ↑ 取回编辑 · Esc 取消任务时放回输入框")
|
|
1371
|
+
queue.update(text)
|
|
1372
|
+
self._status(self._status_note)
|
|
1373
|
+
|
|
1374
|
+
def _refresh_hints(self, value: str) -> None:
|
|
1375
|
+
hints = self.query_one("#command-hints", OptionList)
|
|
1376
|
+
help_line = self.query_one("#command-help", Static)
|
|
1377
|
+
previous = (
|
|
1378
|
+
self._hint_choices[hints.highlighted][0]
|
|
1379
|
+
if hints.highlighted is not None and hints.highlighted < len(self._hint_choices)
|
|
1380
|
+
else None
|
|
1381
|
+
)
|
|
1382
|
+
recalled = self.query_one("#prompt", PromptArea).recalled
|
|
1383
|
+
self._hint_choices = (
|
|
1384
|
+
[] if recalled or value == self._dismissed_prompt else self.commands.matches(self._command_context(), value)
|
|
1385
|
+
)
|
|
1386
|
+
hints.clear_options()
|
|
1387
|
+
width = min(24, max(10, (self.size.width - 8) // 2))
|
|
1388
|
+
for hint in self._hint_choices:
|
|
1389
|
+
row = Table.grid(padding=(0, 2), expand=True)
|
|
1390
|
+
row.add_column(width=width, no_wrap=True, overflow="ellipsis")
|
|
1391
|
+
row.add_column(ratio=1, no_wrap=True, overflow="ellipsis")
|
|
1392
|
+
row.add_row(Text(hint.label or hint.value), Text(hint.description))
|
|
1393
|
+
hints.add_option(row)
|
|
1394
|
+
hints.display = bool(self._hint_choices)
|
|
1395
|
+
help_line.display = hints.display
|
|
1396
|
+
if self._hint_choices:
|
|
1397
|
+
hints.highlighted = next((i for i, choice in enumerate(self._hint_choices) if choice[0] == previous), 0)
|
|
1398
|
+
help_line.update("↑ / ↓ 选择 · Tab 补全 · Enter 确认 · Esc 收起")
|
|
1399
|
+
self.call_later(self._fit_chat)
|
|
1400
|
+
|
|
1401
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
1402
|
+
if action in {"move_hint", "complete_hint"}:
|
|
1403
|
+
field = self.focused
|
|
1404
|
+
if not isinstance(field, PromptArea) or field.id != "prompt" or field.disabled:
|
|
1405
|
+
return False
|
|
1406
|
+
if not self._hint_choices:
|
|
1407
|
+
# After Esc closed the menu, Tab still completes a unique command prefix.
|
|
1408
|
+
return action == "complete_hint" and parameters != (False,) and self._unique_hint() is not None
|
|
1409
|
+
if action == "complete_hint" and parameters == (False,):
|
|
1410
|
+
hints = self.query_one("#command-hints", OptionList)
|
|
1411
|
+
return hints.highlighted is not None and self._hint_choices[hints.highlighted][0] != field.value.strip()
|
|
1412
|
+
return super().check_action(action, parameters)
|
|
1413
|
+
|
|
1414
|
+
def action_move_hint(self, direction: int) -> None:
|
|
1415
|
+
hints = self.query_one("#command-hints", OptionList)
|
|
1416
|
+
if direction < 0:
|
|
1417
|
+
hints.action_cursor_up()
|
|
1418
|
+
else:
|
|
1419
|
+
hints.action_cursor_down()
|
|
1420
|
+
|
|
1421
|
+
def _unique_hint(self) -> Hint | None:
|
|
1422
|
+
value = self.query_one("#prompt", PromptArea).value
|
|
1423
|
+
if value != self._dismissed_prompt or " " in value.strip():
|
|
1424
|
+
return None
|
|
1425
|
+
matches = self.commands.matches(self._command_context(), value)
|
|
1426
|
+
return matches[0] if len(matches) == 1 and matches[0].value != value.strip() else None
|
|
1427
|
+
|
|
1428
|
+
def action_complete_hint(self, tab: bool = True) -> None:
|
|
1429
|
+
hints = self.query_one("#command-hints", OptionList)
|
|
1430
|
+
if not self._hint_choices:
|
|
1431
|
+
unique = self._unique_hint() if tab else None
|
|
1432
|
+
if unique is None:
|
|
1433
|
+
return
|
|
1434
|
+
completion = unique.value
|
|
1435
|
+
elif hints.highlighted is None:
|
|
1436
|
+
return
|
|
1437
|
+
else:
|
|
1438
|
+
completion = self._hint_choices[hints.highlighted].value
|
|
1439
|
+
# Tab continues into the argument candidates of commands that have them.
|
|
1440
|
+
if tab and self.commands.takes_completion(self._command_context(), completion):
|
|
1441
|
+
completion += " "
|
|
1442
|
+
self._dismissed_prompt = None if completion.endswith(" ") else completion
|
|
1443
|
+
field = self.query_one("#prompt", PromptArea)
|
|
1444
|
+
field.value = completion
|
|
1445
|
+
self._refresh_hints(completion)
|
|
1446
|
+
|
|
1447
|
+
@property
|
|
1448
|
+
def _task_running(self) -> bool:
|
|
1449
|
+
return self._reply_task is not None and not self._reply_task.done()
|
|
1450
|
+
|
|
1451
|
+
async def on_prompt_area_submitted(self, event: PromptArea.Submitted) -> None:
|
|
1452
|
+
prompt = event.value
|
|
1453
|
+
field = event.text_area
|
|
1454
|
+
parsed = parse_input(prompt, self.commands)
|
|
1455
|
+
if parsed.kind == "empty":
|
|
1456
|
+
return
|
|
1457
|
+
running_ok = (
|
|
1458
|
+
parsed.kind == "command"
|
|
1459
|
+
and parsed.command is not None
|
|
1460
|
+
and parsed.command.runs_while_running(parsed.argument)
|
|
1461
|
+
)
|
|
1462
|
+
if self._task_running and not running_ok:
|
|
1463
|
+
if parsed.kind != "message":
|
|
1464
|
+
# Commands change state the running task depends on; keep the text for later.
|
|
1465
|
+
self._hint_until = monotonic() + 2
|
|
1466
|
+
self._status(
|
|
1467
|
+
"运行中只能排队消息和使用 /tasks、/status、/worktree list,其他命令请在完成后发送 · Esc 取消"
|
|
1468
|
+
)
|
|
1469
|
+
return
|
|
1470
|
+
field.remember(prompt)
|
|
1471
|
+
field.clear()
|
|
1472
|
+
self.conversation.queue(parsed.text)
|
|
1473
|
+
self._refresh_queue()
|
|
1474
|
+
return
|
|
1475
|
+
field.remember(prompt)
|
|
1476
|
+
field.clear()
|
|
1477
|
+
self._dismissed_prompt = None
|
|
1478
|
+
self._refresh_hints("")
|
|
1479
|
+
if parsed.kind != "message":
|
|
1480
|
+
await self._command(prompt.strip(), parsed)
|
|
1481
|
+
return
|
|
1482
|
+
await self._start_request(parsed.text)
|
|
1483
|
+
|
|
1484
|
+
async def _start_request(
|
|
1485
|
+
self,
|
|
1486
|
+
prompt: str,
|
|
1487
|
+
*,
|
|
1488
|
+
show_user: bool = True,
|
|
1489
|
+
shown: tuple[str, ...] = (),
|
|
1490
|
+
command: str | None = None,
|
|
1491
|
+
fork=None,
|
|
1492
|
+
notify: bool = False,
|
|
1493
|
+
) -> None:
|
|
1494
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1495
|
+
reply = ChatEntry("MossCode")
|
|
1496
|
+
self._active_reply = reply
|
|
1497
|
+
if show_user:
|
|
1498
|
+
for text in shown or (prompt,):
|
|
1499
|
+
await chat.mount(ChatEntry("You", text))
|
|
1500
|
+
await chat.mount(reply)
|
|
1501
|
+
chat.anchor()
|
|
1502
|
+
self.started = monotonic()
|
|
1503
|
+
self._iteration = 0
|
|
1504
|
+
self._generating = True
|
|
1505
|
+
self._status("连接中…")
|
|
1506
|
+
await self._commit_ready_transcript()
|
|
1507
|
+
self._reply_task = asyncio.create_task(self._respond(prompt, reply, command, fork, notify))
|
|
1508
|
+
|
|
1509
|
+
@staticmethod
|
|
1510
|
+
def _note(text: str, style: str | None = None) -> Static:
|
|
1511
|
+
note = Static(Text(text, style=style or ""), classes="local-note")
|
|
1512
|
+
note.transcript = Text(text, style=style or "")
|
|
1513
|
+
return note
|
|
1514
|
+
|
|
1515
|
+
async def _local_note(self, text: str, style: str | None = None) -> None:
|
|
1516
|
+
if self._closing or self._exit: # A note queued just before exit (e.g. a task cancelled on exit) is dropped.
|
|
1517
|
+
return
|
|
1518
|
+
try:
|
|
1519
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1520
|
+
except NoMatches:
|
|
1521
|
+
return
|
|
1522
|
+
if not chat.is_attached:
|
|
1523
|
+
return
|
|
1524
|
+
await chat.mount(self._note(text, style))
|
|
1525
|
+
chat.anchor()
|
|
1526
|
+
await self._commit_ready_transcript()
|
|
1527
|
+
|
|
1528
|
+
@staticmethod
|
|
1529
|
+
def _compact_style(event) -> str | None:
|
|
1530
|
+
return OPERATION_COLOR if isinstance(event, CompactFailed) and event.code != "nothing" else None
|
|
1531
|
+
|
|
1532
|
+
async def _show_compact(self, manual: bool, before=None) -> None:
|
|
1533
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1534
|
+
self._compact_row = CompactRow(manual)
|
|
1535
|
+
self._compacting = self._compact_row.started
|
|
1536
|
+
await chat.mount(self._compact_row, before=before)
|
|
1537
|
+
chat.anchor()
|
|
1538
|
+
self._tick()
|
|
1539
|
+
|
|
1540
|
+
async def _end_compact(self, text: str, style: str | None = None, before=None) -> None:
|
|
1541
|
+
"""The running row becomes the result; without one (nothing to compact) a note is added."""
|
|
1542
|
+
self._compacting = None
|
|
1543
|
+
row, self._compact_row = self._compact_row, None
|
|
1544
|
+
if row is not None:
|
|
1545
|
+
row.finish(text, style)
|
|
1546
|
+
return
|
|
1547
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1548
|
+
await chat.mount(self._note(text, style), before=before)
|
|
1549
|
+
chat.anchor()
|
|
1550
|
+
|
|
1551
|
+
async def _start_compact(self, instructions: str) -> None:
|
|
1552
|
+
self.started = self._compacting = monotonic()
|
|
1553
|
+
self._generating = True
|
|
1554
|
+
self._tick()
|
|
1555
|
+
self._reply_task = asyncio.create_task(self._run_compact(instructions))
|
|
1556
|
+
|
|
1557
|
+
async def _run_compact(self, instructions: str) -> None:
|
|
1558
|
+
try:
|
|
1559
|
+
async with aclosing(self.conversation.compact(instructions)) as stream:
|
|
1560
|
+
async for event in stream:
|
|
1561
|
+
if isinstance(event, CompactStarted):
|
|
1562
|
+
await self._show_compact(event.manual)
|
|
1563
|
+
elif isinstance(event, (CompactFinished, CompactFailed)):
|
|
1564
|
+
await self._end_compact(compact_notice(event), self._compact_style(event))
|
|
1565
|
+
elif isinstance(event, Notice):
|
|
1566
|
+
await self._local_note("! " + event.text, OPERATION_COLOR)
|
|
1567
|
+
await self._commit_ready_transcript()
|
|
1568
|
+
except asyncio.CancelledError:
|
|
1569
|
+
if not self._closing:
|
|
1570
|
+
await self._end_compact(CANCELLED_COMPACT)
|
|
1571
|
+
except ProviderError as exc:
|
|
1572
|
+
await self._end_compact(f"! {exc}", OPERATION_COLOR)
|
|
1573
|
+
finally:
|
|
1574
|
+
self._generating = False
|
|
1575
|
+
self._compacting = None
|
|
1576
|
+
if not self._closing:
|
|
1577
|
+
await self._commit_ready_transcript()
|
|
1578
|
+
field = self.query_one("#prompt", PromptArea)
|
|
1579
|
+
queued = self.conversation.take_queued()
|
|
1580
|
+
if queued:
|
|
1581
|
+
field.value = "\n\n".join((*queued, field.value) if field.value.strip() else queued)
|
|
1582
|
+
self._refresh_queue()
|
|
1583
|
+
self._refresh_context()
|
|
1584
|
+
self._status("")
|
|
1585
|
+
|
|
1586
|
+
async def _clear_screen(self) -> None:
|
|
1587
|
+
"""Like Claude Code's /clear: wipe the screen and scrollback, then start again from the banner."""
|
|
1588
|
+
if not self.is_inline:
|
|
1589
|
+
return
|
|
1590
|
+
async with self._scrollback_lock:
|
|
1591
|
+
self._history = [startup_banner(self.provider_name)]
|
|
1592
|
+
with self.batch_update():
|
|
1593
|
+
redraw_transcript(self, self._history)
|
|
1594
|
+
|
|
1595
|
+
def _start_resume(self, session_id: str, *, startup: bool = False) -> None:
|
|
1596
|
+
self.started = monotonic()
|
|
1597
|
+
self._generating = True
|
|
1598
|
+
self._status("恢复会话中…")
|
|
1599
|
+
self._reply_task = asyncio.create_task(self._run_resume(session_id, startup))
|
|
1600
|
+
|
|
1601
|
+
async def _show_resumed(self, event: Resumed) -> None:
|
|
1602
|
+
restored = event.restored
|
|
1603
|
+
self._usage = self.conversation.total_usage
|
|
1604
|
+
await self._clear_screen()
|
|
1605
|
+
when = restored.last_time.strftime("%Y-%m-%d %H:%M") if restored.last_time else "未知"
|
|
1606
|
+
await self._local_note(f"└ 已恢复会话 · {restored.title} · {event.messages} 条消息 · 上次活动 {when}")
|
|
1607
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1608
|
+
for renderable in resumed_rows(restored.history, shell_label(self.conversation.registry)):
|
|
1609
|
+
row = Static(renderable, classes="resumed-entry")
|
|
1610
|
+
row.transcript = renderable
|
|
1611
|
+
await chat.mount(row)
|
|
1612
|
+
chat.anchor()
|
|
1613
|
+
for notice in event.notices:
|
|
1614
|
+
await self._local_note("└ " + notice)
|
|
1615
|
+
|
|
1616
|
+
async def _run_resume(self, session_id: str, startup: bool = False) -> None:
|
|
1617
|
+
unanswered: tuple[str, ...] = ()
|
|
1618
|
+
try:
|
|
1619
|
+
if startup:
|
|
1620
|
+
await self._confirm_hooks()
|
|
1621
|
+
async with aclosing(self.conversation.resume(session_id)) as stream:
|
|
1622
|
+
async for event in stream:
|
|
1623
|
+
if isinstance(event, Resumed):
|
|
1624
|
+
unanswered = event.restored.unanswered
|
|
1625
|
+
await self._show_resumed(event)
|
|
1626
|
+
elif isinstance(event, CompactStarted):
|
|
1627
|
+
await self._show_compact(event.manual)
|
|
1628
|
+
elif isinstance(event, (CompactFinished, CompactFailed)):
|
|
1629
|
+
await self._end_compact(compact_notice(event), self._compact_style(event))
|
|
1630
|
+
elif isinstance(event, Notice):
|
|
1631
|
+
await self._local_note("! " + event.text, OPERATION_COLOR)
|
|
1632
|
+
await self._commit_ready_transcript()
|
|
1633
|
+
except asyncio.CancelledError:
|
|
1634
|
+
if not self._closing and self._compact_row is not None:
|
|
1635
|
+
await self._end_compact(CANCELLED_COMPACT)
|
|
1636
|
+
except ProviderError as exc:
|
|
1637
|
+
await self._local_note(f"! {exc}", OPERATION_COLOR)
|
|
1638
|
+
finally:
|
|
1639
|
+
self._generating = False
|
|
1640
|
+
self._compacting = None
|
|
1641
|
+
if not self._closing:
|
|
1642
|
+
await self._commit_ready_transcript()
|
|
1643
|
+
field = self.query_one("#prompt", PromptArea)
|
|
1644
|
+
# Unanswered messages come back for the user to edit or resend.
|
|
1645
|
+
texts = [*unanswered, *self.conversation.take_queued()]
|
|
1646
|
+
if texts:
|
|
1647
|
+
field.value = "\n\n".join((*texts, field.value) if field.value.strip() else texts)
|
|
1648
|
+
self._refresh_queue()
|
|
1649
|
+
self._refresh_context()
|
|
1650
|
+
self._status("")
|
|
1651
|
+
if startup:
|
|
1652
|
+
self._resume = None
|
|
1653
|
+
self._startup_task = asyncio.create_task(self._initialize())
|
|
1654
|
+
|
|
1655
|
+
def _show_panel(self, title: str, rows: int) -> None:
|
|
1656
|
+
self._panel_rows = rows
|
|
1657
|
+
self.query_one("#model-title", Static).update(title)
|
|
1658
|
+
options = self.query_one("#models", OptionList)
|
|
1659
|
+
# One row per choice, so the panel height reserved in _fit_chat holds.
|
|
1660
|
+
options.styles.height = min(8, rows)
|
|
1661
|
+
self.query_one("#model-panel").display = True
|
|
1662
|
+
options.focus()
|
|
1663
|
+
self._show_members() # Hidden while the model panel is open.
|
|
1664
|
+
self.call_later(self._fit_chat)
|
|
1665
|
+
|
|
1666
|
+
async def _command(self, typed: str, parsed: Parsed) -> None:
|
|
1667
|
+
"""Echo what was typed, then run it; prompt commands echo the command, not the template."""
|
|
1668
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1669
|
+
await chat.mount(ChatEntry("You", typed))
|
|
1670
|
+
chat.anchor()
|
|
1671
|
+
await self._commit_ready_transcript()
|
|
1672
|
+
if parsed.kind == "unknown":
|
|
1673
|
+
await self._local_note(unknown_note(parsed, self.commands))
|
|
1674
|
+
return
|
|
1675
|
+
try:
|
|
1676
|
+
await dispatch(self._command_context(), parsed)
|
|
1677
|
+
except ProviderError as exc:
|
|
1678
|
+
await self._local_note(f"! {exc}", OPERATION_COLOR)
|
|
1679
|
+
|
|
1680
|
+
def _command_context(self) -> CommandContext:
|
|
1681
|
+
return CommandContext(self.conversation, self, self.commands, self.settings)
|
|
1682
|
+
|
|
1683
|
+
# CommandUI -------------------------------------------------------
|
|
1684
|
+
|
|
1685
|
+
async def note(self, text: str, style: str | None = None) -> None:
|
|
1686
|
+
await self._local_note(text, style)
|
|
1687
|
+
|
|
1688
|
+
async def submit(self, prompt: str, *, command: str | None = None, fork=None) -> None:
|
|
1689
|
+
await self._start_request(prompt, show_user=False, command=command, fork=fork)
|
|
1690
|
+
|
|
1691
|
+
def refresh_status(self) -> None:
|
|
1692
|
+
self._usage = self.conversation.total_usage
|
|
1693
|
+
self._refresh_context()
|
|
1694
|
+
self._status("")
|
|
1695
|
+
|
|
1696
|
+
async def clear_screen(self) -> None:
|
|
1697
|
+
await self._clear_screen()
|
|
1698
|
+
|
|
1699
|
+
def choose(self, title: str, options, current: int, on_pick, on_cancel=None) -> None:
|
|
1700
|
+
picker = self.query_one("#models", OptionList)
|
|
1701
|
+
picker.clear_options()
|
|
1702
|
+
picker.add_options(list(options))
|
|
1703
|
+
self._pick, self._on_cancel = on_pick, on_cancel
|
|
1704
|
+
self._show_panel(title, len(options))
|
|
1705
|
+
picker.highlighted = current
|
|
1706
|
+
|
|
1707
|
+
async def compact(self, instructions: str) -> None:
|
|
1708
|
+
await self._start_compact(instructions)
|
|
1709
|
+
|
|
1710
|
+
def resume(self, session_id: str) -> None:
|
|
1711
|
+
self._start_resume(session_id)
|
|
1712
|
+
|
|
1713
|
+
async def switch_model(self, name: str) -> None:
|
|
1714
|
+
config = next(item for item in self.settings.providers if item.name == name)
|
|
1715
|
+
await self._switch_model(config)
|
|
1716
|
+
|
|
1717
|
+
async def _switch_model(self, config: ProviderConfig) -> None:
|
|
1718
|
+
if config.name == self.provider_name:
|
|
1719
|
+
await self._local_note(f"└ 已在使用 {config.name}({config.model})。")
|
|
1720
|
+
return
|
|
1721
|
+
try:
|
|
1722
|
+
provider = self.provider_factory(config)
|
|
1723
|
+
await self.conversation.set_provider(
|
|
1724
|
+
provider, config.budget(), (config.name, config.protocol, config.model)
|
|
1725
|
+
)
|
|
1726
|
+
except (ProviderError, ConfigError) as exc:
|
|
1727
|
+
await self._local_note(f"切换失败:{exc}")
|
|
1728
|
+
return
|
|
1729
|
+
self.provider_name, self.model = config.name, config.model
|
|
1730
|
+
self._refresh_context()
|
|
1731
|
+
self._status("")
|
|
1732
|
+
await self._local_note(f"└ 已切换到 {config.name}({config.protocol} / {config.model});对话历史保留。")
|
|
1733
|
+
if config.context_window is None:
|
|
1734
|
+
await self._local_note(f"└ {config.name} 未配置 context_window,按 128K 估算上下文。")
|
|
1735
|
+
|
|
1736
|
+
def _close_model_panel(self) -> None:
|
|
1737
|
+
self._pick = self._on_cancel = None
|
|
1738
|
+
self.query_one("#model-panel").display = False
|
|
1739
|
+
self.query_one("#prompt", PromptArea).focus()
|
|
1740
|
+
self._show_members()
|
|
1741
|
+
self.call_later(self._fit_chat)
|
|
1742
|
+
|
|
1743
|
+
async def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1744
|
+
if event.option_list.id == "models":
|
|
1745
|
+
pick = self._pick
|
|
1746
|
+
self._close_model_panel()
|
|
1747
|
+
if pick is not None:
|
|
1748
|
+
await pick(event.option_index)
|
|
1749
|
+
elif event.option_list.id == "approval-options" and self._approval_request:
|
|
1750
|
+
self._answer_approval(self._approval_request.choices[event.option_index])
|
|
1751
|
+
elif event.option_list.id == "member-list": # Enter opens the member view.
|
|
1752
|
+
rows = self.query_one("#member-panel", MemberPanel).rows
|
|
1753
|
+
if event.option_index < len(rows):
|
|
1754
|
+
await self._open_view(rows[event.option_index].name)
|
|
1755
|
+
|
|
1756
|
+
def _waiting(self, request: ApprovalRequest) -> bool:
|
|
1757
|
+
registry = self.conversation.registry
|
|
1758
|
+
future = registry.permissions.pending.get(request.id) if registry is not None else None
|
|
1759
|
+
return future is not None and not future.done()
|
|
1760
|
+
|
|
1761
|
+
def _member_approval(self, request: ApprovalRequest) -> None:
|
|
1762
|
+
"""A member's request; once the window is closing there is no card to show, so it is refused."""
|
|
1763
|
+
if self._closing or not self.query("#approval-panel"):
|
|
1764
|
+
teams = getattr(self.conversation, "teams", None)
|
|
1765
|
+
if teams is not None and request.id in teams.forwarded:
|
|
1766
|
+
return # A pane member still asks in its own window.
|
|
1767
|
+
if self.conversation.registry is not None:
|
|
1768
|
+
self.conversation.registry.permissions.respond(request.id, "deny")
|
|
1769
|
+
return
|
|
1770
|
+
self._show_approval(request)
|
|
1771
|
+
|
|
1772
|
+
def _show_approval(self, request: ApprovalRequest) -> None:
|
|
1773
|
+
if self._approval_request is not None and self._approval_request.id != request.id:
|
|
1774
|
+
if all(item.id != request.id for item in self._approval_queue):
|
|
1775
|
+
self._approval_queue.append(request) # After the one on screen.
|
|
1776
|
+
self._status(self._status_note)
|
|
1777
|
+
return
|
|
1778
|
+
self._approval_request = request
|
|
1779
|
+
operation = request.operation
|
|
1780
|
+
labels = {
|
|
1781
|
+
"read_file": "读取文件",
|
|
1782
|
+
"write_file": "写入文件",
|
|
1783
|
+
"edit_file": "修改文件",
|
|
1784
|
+
"glob": "查找文件",
|
|
1785
|
+
"grep": "搜索内容",
|
|
1786
|
+
"exit_plan_mode": "审阅计划",
|
|
1787
|
+
"execute_command": shell_name(shell_label(self.conversation.registry)),
|
|
1788
|
+
}
|
|
1789
|
+
text = Text()
|
|
1790
|
+
if operation.tool == "team_merge":
|
|
1791
|
+
text.append(f"需要确认 · 合并成员 {safe_display(operation.target)} 的分支", style=f"bold {OPERATION_COLOR}")
|
|
1792
|
+
else:
|
|
1793
|
+
text.append(
|
|
1794
|
+
"需要授权 · "
|
|
1795
|
+
+ labels.get(operation.tool, operation.tool)
|
|
1796
|
+
+ (f" · {request.source}" if request.source else "")
|
|
1797
|
+
+ "\n",
|
|
1798
|
+
style=f"bold {OPERATION_COLOR}",
|
|
1799
|
+
)
|
|
1800
|
+
text.append(safe_display(operation.target), style=PARAMETER_COLOR)
|
|
1801
|
+
if operation.cwd is not None:
|
|
1802
|
+
text.append("\n工作目录:" + operation.cwd)
|
|
1803
|
+
text.append("\n" + request.reason)
|
|
1804
|
+
if operation.tool == "exit_plan_mode":
|
|
1805
|
+
text.append("\n完整计划显示在上方对话中。")
|
|
1806
|
+
elif operation.preview:
|
|
1807
|
+
text.append("\n")
|
|
1808
|
+
text.append_text(preview_text(operation.preview))
|
|
1809
|
+
self.query_one("#approval-detail", Static).update(text)
|
|
1810
|
+
self.query_one("#approval-panel").display = True
|
|
1811
|
+
options = self.query_one("#approval-options", OptionList)
|
|
1812
|
+
options.clear_options()
|
|
1813
|
+
options.add_options([choice_label(request, choice) for choice in request.choices])
|
|
1814
|
+
options.styles.height = len(request.choices)
|
|
1815
|
+
options.highlighted = 0
|
|
1816
|
+
members = self.query_one("#member-panel", MemberPanel)
|
|
1817
|
+
if members.expanded: # Compact while a card waits.
|
|
1818
|
+
self._stop_armed = None
|
|
1819
|
+
members.collapse(monotonic())
|
|
1820
|
+
options.focus()
|
|
1821
|
+
self._show_members()
|
|
1822
|
+
if not self._generating: # A member's request while the lead waits for input.
|
|
1823
|
+
self._status("等待授权 · Esc 拒绝本次")
|
|
1824
|
+
else:
|
|
1825
|
+
self._status(self._status_note) # The member approval count.
|
|
1826
|
+
self._tick()
|
|
1827
|
+
self.call_later(self._fit_chat)
|
|
1828
|
+
|
|
1829
|
+
def _hide_approval(self) -> None:
|
|
1830
|
+
"""The next request still waiting stays or comes up (a member's outlives the lead's turn)."""
|
|
1831
|
+
waiting = [
|
|
1832
|
+
item
|
|
1833
|
+
for item in ([self._approval_request] if self._approval_request else []) + self._approval_queue
|
|
1834
|
+
if self._waiting(item)
|
|
1835
|
+
]
|
|
1836
|
+
self._approval_request = None
|
|
1837
|
+
self._approval_queue = waiting[1:]
|
|
1838
|
+
if waiting and not self._closing:
|
|
1839
|
+
self._show_approval(waiting[0])
|
|
1840
|
+
return
|
|
1841
|
+
self.query_one("#approval-panel").display = False
|
|
1842
|
+
if not self._closing:
|
|
1843
|
+
# The prompt stays usable while the task runs, e.g. to queue a correction.
|
|
1844
|
+
self._focus_home()
|
|
1845
|
+
self._show_members()
|
|
1846
|
+
self.call_later(self._fit_chat)
|
|
1847
|
+
|
|
1848
|
+
def _withdraw_approval(self, request_id: str) -> None:
|
|
1849
|
+
"""A pane member's request answered in its window, or no longer asked."""
|
|
1850
|
+
if self._closing:
|
|
1851
|
+
return
|
|
1852
|
+
if self._approval_request is not None and self._approval_request.id == request_id:
|
|
1853
|
+
self._hide_approval() # Its future is cancelled: the next one still waiting comes up.
|
|
1854
|
+
if self._approval_request is None and not self._generating:
|
|
1855
|
+
self._status("")
|
|
1856
|
+
else:
|
|
1857
|
+
self._approval_queue = [item for item in self._approval_queue if item.id != request_id]
|
|
1858
|
+
self._status(self._status_note)
|
|
1859
|
+
self._show_members()
|
|
1860
|
+
|
|
1861
|
+
def _answer_approval(self, choice: str) -> None:
|
|
1862
|
+
if self._approval_request and self.conversation.registry:
|
|
1863
|
+
request, self._approval_request = self._approval_request, None
|
|
1864
|
+
self.conversation.registry.permissions.respond(request.id, choice)
|
|
1865
|
+
self._hide_approval()
|
|
1866
|
+
if request.operation.tool == "team_merge" and choice == "deny":
|
|
1867
|
+
self.call_later(self._local_note, DECLINED_HINT) # The reason is the user's next message.
|
|
1868
|
+
if self._approval_request is None and not self._generating:
|
|
1869
|
+
self._status("")
|
|
1870
|
+
self._tick()
|
|
1871
|
+
|
|
1872
|
+
async def _respond(
|
|
1873
|
+
self, prompt: str, reply: ChatEntry, command: str | None = None, fork=None, notify: bool = False
|
|
1874
|
+
) -> None:
|
|
1875
|
+
chat = self.query_one("#chat", VerticalScroll)
|
|
1876
|
+
tool_rows: dict[str, ToolEntry] = {}
|
|
1877
|
+
continuation = False
|
|
1878
|
+
completed = False
|
|
1879
|
+
|
|
1880
|
+
try:
|
|
1881
|
+
turn = self.conversation.notify() if notify else self.conversation.reply(prompt, command=command, fork=fork)
|
|
1882
|
+
async with aclosing(turn) as stream:
|
|
1883
|
+
async for event in stream:
|
|
1884
|
+
if not isinstance(event, Retrying):
|
|
1885
|
+
self._retry_notice = ""
|
|
1886
|
+
if isinstance(event, IterationProgress):
|
|
1887
|
+
self._iteration, self._iteration_limit = event.iteration, event.max_iterations
|
|
1888
|
+
reply.phase = "waiting"
|
|
1889
|
+
self._tick()
|
|
1890
|
+
elif isinstance(event, UsageUpdate):
|
|
1891
|
+
self._usage = event.total
|
|
1892
|
+
self._refresh_context()
|
|
1893
|
+
self._tick()
|
|
1894
|
+
elif isinstance(event, CompactStarted):
|
|
1895
|
+
# Before a reply that has not started yet, so the row reads in time order.
|
|
1896
|
+
await self._show_compact(event.manual, None if continuation else reply)
|
|
1897
|
+
elif isinstance(event, (CompactFinished, CompactFailed)):
|
|
1898
|
+
await self._end_compact(
|
|
1899
|
+
compact_notice(event), self._compact_style(event), None if continuation else reply
|
|
1900
|
+
)
|
|
1901
|
+
self._refresh_context()
|
|
1902
|
+
elif isinstance(event, RunStopped):
|
|
1903
|
+
completed = event.reason == "completed"
|
|
1904
|
+
for result in event.results:
|
|
1905
|
+
if result.call_id in tool_rows:
|
|
1906
|
+
tool_rows[result.call_id].complete(result)
|
|
1907
|
+
if event.reason in {"max_iterations", "unknown_tools", "max_tokens"}:
|
|
1908
|
+
await self._local_note("└ " + event.notice)
|
|
1909
|
+
self._status("已停止 · 可以继续提问")
|
|
1910
|
+
elif isinstance(event, Retrying):
|
|
1911
|
+
self._retry_notice = f"{event.reason} · {event.delay:.0f} 秒后重试({event.attempt}/{event.max_attempts})· Esc 取消"
|
|
1912
|
+
self._status(self._retry_notice)
|
|
1913
|
+
elif isinstance(event, Delta):
|
|
1914
|
+
if continuation:
|
|
1915
|
+
reply = ChatEntry("MossCode")
|
|
1916
|
+
reply.started = self.started
|
|
1917
|
+
await chat.mount(reply)
|
|
1918
|
+
self._active_reply = reply
|
|
1919
|
+
continuation = False
|
|
1920
|
+
reply.append(event)
|
|
1921
|
+
self._tick()
|
|
1922
|
+
elif isinstance(event, ResponseBoundary):
|
|
1923
|
+
if continuation:
|
|
1924
|
+
reply = ChatEntry("MossCode")
|
|
1925
|
+
reply.started = self.started
|
|
1926
|
+
await chat.mount(reply)
|
|
1927
|
+
self._active_reply = reply
|
|
1928
|
+
await reply.seal_tool_request(event.message)
|
|
1929
|
+
tool_rows = {}
|
|
1930
|
+
continuation = True
|
|
1931
|
+
elif isinstance(event, ToolProgress):
|
|
1932
|
+
if event.phase == "parsing":
|
|
1933
|
+
self._status("解析工具参数… · Esc 取消")
|
|
1934
|
+
else:
|
|
1935
|
+
if event.phase == "running":
|
|
1936
|
+
reply.phase = "tools"
|
|
1937
|
+
row = tool_rows.get(event.call.id)
|
|
1938
|
+
if row is None:
|
|
1939
|
+
if event.call.name == "exit_plan_mode":
|
|
1940
|
+
plan = plan_text(event.call.arguments)
|
|
1941
|
+
if plan:
|
|
1942
|
+
await chat.mount(PlanEntry(plan))
|
|
1943
|
+
row = ToolEntry(event, shell_label(self.conversation.registry))
|
|
1944
|
+
tool_rows[event.call.id] = row
|
|
1945
|
+
if event.call.name == "agent":
|
|
1946
|
+
self._agent_rows[event.call.id] = row
|
|
1947
|
+
await chat.mount(row)
|
|
1948
|
+
if event.result is not None:
|
|
1949
|
+
row.complete(event.result, event.elapsed)
|
|
1950
|
+
reply.phase = (
|
|
1951
|
+
"tools" if any(r.elapsed is None for r in tool_rows.values()) else "waiting"
|
|
1952
|
+
)
|
|
1953
|
+
self._tick()
|
|
1954
|
+
elif isinstance(event, Notice):
|
|
1955
|
+
await self._local_note("! " + event.text, OPERATION_COLOR)
|
|
1956
|
+
elif isinstance(event, UserInput):
|
|
1957
|
+
# Queued messages joined the context at this iteration boundary.
|
|
1958
|
+
for text in event.texts:
|
|
1959
|
+
await chat.mount(ChatEntry("You", text))
|
|
1960
|
+
chat.anchor()
|
|
1961
|
+
self._refresh_queue()
|
|
1962
|
+
elif isinstance(event, ApprovalRequest):
|
|
1963
|
+
self._show_approval(event)
|
|
1964
|
+
if event.call_id in tool_rows:
|
|
1965
|
+
row = tool_rows[event.call_id]
|
|
1966
|
+
row.update(row._heading("running") + Text("\n └ 等待用户确认…", style=OPERATION_COLOR))
|
|
1967
|
+
elif isinstance(event, Completed):
|
|
1968
|
+
if continuation:
|
|
1969
|
+
reply = ChatEntry("MossCode")
|
|
1970
|
+
reply.started = self.started
|
|
1971
|
+
await chat.mount(reply)
|
|
1972
|
+
self._active_reply = reply
|
|
1973
|
+
self._generating = False
|
|
1974
|
+
await reply.finish(event.message)
|
|
1975
|
+
self._status("")
|
|
1976
|
+
await self._commit_ready_transcript()
|
|
1977
|
+
except asyncio.CancelledError:
|
|
1978
|
+
if not self._closing:
|
|
1979
|
+
reply.interrupted("已取消,部分回复未加入对话历史。已执行的工具结果保留。")
|
|
1980
|
+
self._status("已取消 · 可以重新发送")
|
|
1981
|
+
except ProviderError as exc:
|
|
1982
|
+
reply.interrupted(str(exc))
|
|
1983
|
+
self._status("失败 · 未完成正文未保存,已执行工具结果保留")
|
|
1984
|
+
except Exception:
|
|
1985
|
+
reply.interrupted("出现内部错误,请检查安装环境。")
|
|
1986
|
+
self._status("失败 · 未完成正文未保存,已执行工具结果保留")
|
|
1987
|
+
finally:
|
|
1988
|
+
if self._compact_row is not None:
|
|
1989
|
+
await self._end_compact(CANCELLED_COMPACT)
|
|
1990
|
+
self._generating = False
|
|
1991
|
+
self._compacting = None
|
|
1992
|
+
self._retry_notice = ""
|
|
1993
|
+
self._active_reply = None
|
|
1994
|
+
self._agent_rows = {}
|
|
1995
|
+
self._refresh_context()
|
|
1996
|
+
if not self._closing:
|
|
1997
|
+
self._hide_approval()
|
|
1998
|
+
await self._commit_ready_transcript()
|
|
1999
|
+
field = self.query_one("#prompt", PromptArea)
|
|
2000
|
+
field.disabled = False
|
|
2001
|
+
if self._approval_request is None: # A member's card still waiting keeps the keys.
|
|
2002
|
+
self._focus_home() # A member view or the open panel keeps them too.
|
|
2003
|
+
queued = self.conversation.take_queued()
|
|
2004
|
+
if queued and completed:
|
|
2005
|
+
# The model answered before the queue could join; send it as the next task.
|
|
2006
|
+
self.call_later(self._start_request, "\n\n".join(queued), shown=queued)
|
|
2007
|
+
elif queued:
|
|
2008
|
+
# Stopped, cancelled or failed: never resend automatically.
|
|
2009
|
+
field.value = "\n\n".join((*queued, field.value) if field.value.strip() else queued)
|
|
2010
|
+
self._refresh_queue()
|
|
2011
|
+
notices, self._mcp_notices = self._mcp_notices, []
|
|
2012
|
+
for notice in notices:
|
|
2013
|
+
await self._local_note("└ " + notice)
|
|
2014
|
+
|
|
2015
|
+
# Team members -------------------------------------------------------
|
|
2016
|
+
|
|
2017
|
+
def _asking(self) -> dict[str, int]:
|
|
2018
|
+
"""Waiting approvals by the member asking."""
|
|
2019
|
+
teams = getattr(self.conversation, "teams", None)
|
|
2020
|
+
counts: dict[str, int] = {}
|
|
2021
|
+
if teams is None:
|
|
2022
|
+
return counts
|
|
2023
|
+
for item in [self._approval_request, *self._approval_queue]:
|
|
2024
|
+
if item is not None and self._waiting(item):
|
|
2025
|
+
name = teams.asker(item.id)
|
|
2026
|
+
if name:
|
|
2027
|
+
counts[name] = counts.get(name, 0) + 1
|
|
2028
|
+
return counts
|
|
2029
|
+
|
|
2030
|
+
def _members_changed(self) -> None:
|
|
2031
|
+
self._status(self._status_note)
|
|
2032
|
+
self._schedule_members()
|
|
2033
|
+
|
|
2034
|
+
def _schedule_members(self) -> None:
|
|
2035
|
+
"""The rows are read off the loop; a change during a read reads again."""
|
|
2036
|
+
if self._closing:
|
|
2037
|
+
return
|
|
2038
|
+
if self._members_task is not None and not self._members_task.done():
|
|
2039
|
+
self._members_again = True
|
|
2040
|
+
return
|
|
2041
|
+
self._members_task = asyncio.create_task(self._refresh_members())
|
|
2042
|
+
|
|
2043
|
+
async def _refresh_members(self) -> None:
|
|
2044
|
+
teams = getattr(self.conversation, "teams", None)
|
|
2045
|
+
while not self._closing:
|
|
2046
|
+
self._members_again = False
|
|
2047
|
+
try:
|
|
2048
|
+
rows = await teams.rows() if teams is not None else []
|
|
2049
|
+
except Exception:
|
|
2050
|
+
rows = []
|
|
2051
|
+
if self._closing:
|
|
2052
|
+
return
|
|
2053
|
+
self._member_rows = rows
|
|
2054
|
+
self._show_members()
|
|
2055
|
+
if not self._members_again:
|
|
2056
|
+
return
|
|
2057
|
+
|
|
2058
|
+
def _members_mounted(self) -> bool:
|
|
2059
|
+
# Timers keep firing while the app shuts down, when its widgets go one at a time.
|
|
2060
|
+
return not self._closing and bool(self.query("#member-panel")) and bool(self.query("#member-view"))
|
|
2061
|
+
|
|
2062
|
+
def _show_members(self) -> None:
|
|
2063
|
+
"""The member panel from the rows already read, and the member view."""
|
|
2064
|
+
if not self._members_mounted():
|
|
2065
|
+
return
|
|
2066
|
+
teams = getattr(self.conversation, "teams", None)
|
|
2067
|
+
current = teams.current if teams is not None else None
|
|
2068
|
+
rows = self._member_rows if current is not None else []
|
|
2069
|
+
asking, now = self._asking(), monotonic()
|
|
2070
|
+
panel = self.query_one("#member-panel", MemberPanel)
|
|
2071
|
+
view = self.query_one("#member-view", MemberView)
|
|
2072
|
+
if view.display and (current is None or current.name != view.team):
|
|
2073
|
+
self._close_view(to_panel=False)
|
|
2074
|
+
self.call_later(self._local_note, f"└ 团队 {view.team} 已不是当前团队,成员视图已关闭")
|
|
2075
|
+
if panel.expanded and not rows:
|
|
2076
|
+
panel.collapse(now)
|
|
2077
|
+
self._focus_home()
|
|
2078
|
+
panel.show(rows, asking, now)
|
|
2079
|
+
shown = not view.display and not self.query_one("#model-panel").display and (panel.expanded or panel.live)
|
|
2080
|
+
if panel.display != shown:
|
|
2081
|
+
panel.display = shown
|
|
2082
|
+
if not shown and self.focused is self.query_one("#member-list"):
|
|
2083
|
+
self._focus_home()
|
|
2084
|
+
if view.display:
|
|
2085
|
+
view.head(next((row for row in rows if row.name == view.name_shown), None), asking)
|
|
2086
|
+
self._status(self._status_note)
|
|
2087
|
+
self.call_later(self._fit_chat)
|
|
2088
|
+
|
|
2089
|
+
def _member_tick(self) -> None:
|
|
2090
|
+
"""A pane member's transcript every 0.5s; The rows every second while something moves."""
|
|
2091
|
+
if not self._members_mounted():
|
|
2092
|
+
return
|
|
2093
|
+
view = self.query_one("#member-view", MemberView)
|
|
2094
|
+
if view.display:
|
|
2095
|
+
view.seen()
|
|
2096
|
+
self.call_later(self._pull_view)
|
|
2097
|
+
self._member_ticks += 1
|
|
2098
|
+
if self._member_ticks % 2:
|
|
2099
|
+
return
|
|
2100
|
+
panel = self.query_one("#member-panel", MemberPanel)
|
|
2101
|
+
if view.display or panel.expanded or any(row.status == "running" for row in self._member_rows):
|
|
2102
|
+
self._schedule_members()
|
|
2103
|
+
|
|
2104
|
+
def _focus_home(self) -> None:
|
|
2105
|
+
"""Where the keys go back to: the member view, the expanded panel, or the prompt."""
|
|
2106
|
+
if self.query_one("#member-view").display:
|
|
2107
|
+
self.query_one("#member-view", MemberView).member_log.focus()
|
|
2108
|
+
elif self.query_one("#member-panel").display and self.query_one("#member-panel", MemberPanel).expanded:
|
|
2109
|
+
self.query_one("#member-list", MemberList).focus()
|
|
2110
|
+
else:
|
|
2111
|
+
self.query_one("#prompt", PromptArea).focus()
|
|
2112
|
+
|
|
2113
|
+
def action_members(self) -> None:
|
|
2114
|
+
"""Ctrl+T opens the panel to pick from, and closes it (or the member view) again."""
|
|
2115
|
+
if self._approval_request or self.query_one("#model-panel").display:
|
|
2116
|
+
return
|
|
2117
|
+
if self.query_one("#member-view").display:
|
|
2118
|
+
self._close_view(to_panel=False)
|
|
2119
|
+
return
|
|
2120
|
+
panel = self.query_one("#member-panel", MemberPanel)
|
|
2121
|
+
if panel.expanded:
|
|
2122
|
+
self._collapse_members()
|
|
2123
|
+
return
|
|
2124
|
+
teams = getattr(self.conversation, "teams", None)
|
|
2125
|
+
if teams is None or teams.current is None:
|
|
2126
|
+
self._hint_until = monotonic() + 2
|
|
2127
|
+
self._status("没有当前团队 · 让模型用 team_create 建一个")
|
|
2128
|
+
return
|
|
2129
|
+
if not self._member_rows:
|
|
2130
|
+
self._hint_until = monotonic() + 2
|
|
2131
|
+
self._status(f"团队 {teams.current.name} 还没有成员")
|
|
2132
|
+
self._schedule_members()
|
|
2133
|
+
return
|
|
2134
|
+
panel.display = True
|
|
2135
|
+
panel.expand(monotonic())
|
|
2136
|
+
self._show_members()
|
|
2137
|
+
|
|
2138
|
+
def _collapse_members(self) -> None:
|
|
2139
|
+
self._stop_armed = None
|
|
2140
|
+
self.query_one("#member-panel", MemberPanel).collapse(monotonic())
|
|
2141
|
+
self.query_one("#prompt", PromptArea).focus()
|
|
2142
|
+
self._show_members()
|
|
2143
|
+
|
|
2144
|
+
def action_stop_member(self) -> None:
|
|
2145
|
+
"""X twice within STOP_CONFIRM seconds, as /team stop."""
|
|
2146
|
+
panel = self.query_one("#member-panel", MemberPanel)
|
|
2147
|
+
name = panel.selected
|
|
2148
|
+
row = next((row for row in panel.rows if row.name == name), None)
|
|
2149
|
+
if row is None:
|
|
2150
|
+
return
|
|
2151
|
+
if row.status == "stopped":
|
|
2152
|
+
panel.help(f"成员 {name} 已经停止 · " + PANEL_HELP)
|
|
2153
|
+
return
|
|
2154
|
+
armed = self._stop_armed
|
|
2155
|
+
if armed is not None and armed[0] == name and monotonic() <= armed[1]:
|
|
2156
|
+
self._stop_armed = None
|
|
2157
|
+
panel.help(PANEL_HELP)
|
|
2158
|
+
self.call_later(self._stop_member, name)
|
|
2159
|
+
return
|
|
2160
|
+
self._stop_armed = (name, monotonic() + STOP_CONFIRM)
|
|
2161
|
+
panel.help(f"再按 x 停止成员 {name}")
|
|
2162
|
+
self.set_timer(STOP_CONFIRM + 0.05, self._disarm_stop)
|
|
2163
|
+
|
|
2164
|
+
def _disarm_stop(self) -> None:
|
|
2165
|
+
if not self._members_mounted() or self._stop_armed is None or monotonic() <= self._stop_armed[1]:
|
|
2166
|
+
return
|
|
2167
|
+
self._stop_armed = None
|
|
2168
|
+
self.query_one("#member-panel", MemberPanel).help(PANEL_HELP)
|
|
2169
|
+
|
|
2170
|
+
async def _stop_member(self, name: str) -> None:
|
|
2171
|
+
try:
|
|
2172
|
+
text = await self.conversation.teams.stop_member(name)
|
|
2173
|
+
except TeamError as exc:
|
|
2174
|
+
await self._local_note("! " + exc.message, OPERATION_COLOR)
|
|
2175
|
+
return
|
|
2176
|
+
await self._local_note("└ " + text)
|
|
2177
|
+
|
|
2178
|
+
async def _open_view(self, name: str) -> None:
|
|
2179
|
+
"""In place of the lead's running items, scrolled to the bottom."""
|
|
2180
|
+
teams = getattr(self.conversation, "teams", None)
|
|
2181
|
+
path = teams.transcript(name) if teams is not None else None
|
|
2182
|
+
if path is None:
|
|
2183
|
+
return
|
|
2184
|
+
self._stop_armed = None
|
|
2185
|
+
view = self.query_one("#member-view", MemberView)
|
|
2186
|
+
view.reset(name, teams.current.name, TranscriptTail(path))
|
|
2187
|
+
self.query_one("#chat").display = False
|
|
2188
|
+
self.query_one("#input-row").display = False # Read-only.
|
|
2189
|
+
self.query_one("#command-hints").display = self.query_one("#command-help").display = False
|
|
2190
|
+
view.display = True
|
|
2191
|
+
view.member_log.focus()
|
|
2192
|
+
self._show_members()
|
|
2193
|
+
self._fit_chat()
|
|
2194
|
+
await self._pull_view(initial=True)
|
|
2195
|
+
|
|
2196
|
+
def _close_view(self, *, to_panel: bool = True) -> None:
|
|
2197
|
+
"""Back to the panel (or the prompt); the lead's items come back."""
|
|
2198
|
+
view = self.query_one("#member-view", MemberView)
|
|
2199
|
+
if not view.display:
|
|
2200
|
+
return
|
|
2201
|
+
name = view.name_shown
|
|
2202
|
+
view.display, view.tail = False, None
|
|
2203
|
+
self.query_one("#chat").display = True
|
|
2204
|
+
self.query_one("#input-row").display = True
|
|
2205
|
+
panel = self.query_one("#member-panel", MemberPanel)
|
|
2206
|
+
self._show_members()
|
|
2207
|
+
if to_panel and self._member_rows:
|
|
2208
|
+
panel.display = True
|
|
2209
|
+
panel.expand(monotonic(), select=name)
|
|
2210
|
+
else:
|
|
2211
|
+
if panel.expanded:
|
|
2212
|
+
panel.collapse(monotonic())
|
|
2213
|
+
self.query_one("#prompt", PromptArea).focus()
|
|
2214
|
+
self._refresh_hints(self.query_one("#prompt", PromptArea).value)
|
|
2215
|
+
self._show_members()
|
|
2216
|
+
self.call_later(self._fit_chat)
|
|
2217
|
+
|
|
2218
|
+
def action_member_step(self, step: int) -> None:
|
|
2219
|
+
"""←/→ in the panel's order."""
|
|
2220
|
+
view = self.query_one("#member-view", MemberView)
|
|
2221
|
+
names = [row.name for row in self._member_rows]
|
|
2222
|
+
if not view.display or len(names) < 2:
|
|
2223
|
+
return
|
|
2224
|
+
index = names.index(view.name_shown) if view.name_shown in names else 0
|
|
2225
|
+
self.call_later(self._open_view, names[(index + step) % len(names)])
|
|
2226
|
+
|
|
2227
|
+
def _member_committed(self, name: str) -> None:
|
|
2228
|
+
"""An in-process member's transcript grew; read it now rather than at the next tick."""
|
|
2229
|
+
if self._closing or not self.query("#member-view"):
|
|
2230
|
+
return
|
|
2231
|
+
view = self.query_one("#member-view", MemberView)
|
|
2232
|
+
if view.display and view.name_shown == name:
|
|
2233
|
+
self.call_later(self._pull_view)
|
|
2234
|
+
|
|
2235
|
+
async def _pull_view(self, initial: bool = False) -> None:
|
|
2236
|
+
"""The records added since the last read, as lines; reads never overlap."""
|
|
2237
|
+
view = self.query_one("#member-view", MemberView)
|
|
2238
|
+
async with self._view_lock:
|
|
2239
|
+
tail = view.tail
|
|
2240
|
+
if tail is None or not view.display:
|
|
2241
|
+
return
|
|
2242
|
+
name = view.name_shown
|
|
2243
|
+
try:
|
|
2244
|
+
records = await asyncio.to_thread(tail.read)
|
|
2245
|
+
except OSError as exc:
|
|
2246
|
+
if initial:
|
|
2247
|
+
view.write([Text(f"无法读取成员 {name} 的对话记录:{exc}", style=ERROR_COLOR)], follow=True)
|
|
2248
|
+
return
|
|
2249
|
+
if view.tail is not tail or self._closing:
|
|
2250
|
+
return # Switched to another member meanwhile.
|
|
2251
|
+
lines = []
|
|
2252
|
+
if initial:
|
|
2253
|
+
messages = [index for index, record in enumerate(records) if record.get("type") == "message"]
|
|
2254
|
+
if len(messages) > VIEW_MESSAGES:
|
|
2255
|
+
skipped = len(messages) - VIEW_MESSAGES
|
|
2256
|
+
records = records[messages[skipped] :]
|
|
2257
|
+
lines.append(Text(f"(更早的 {skipped} 条消息未显示)", style="dim"))
|
|
2258
|
+
if not records and not await asyncio.to_thread(tail.path.exists):
|
|
2259
|
+
lines.append(Text(f"成员 {name} 还没有对话记录", style="dim"))
|
|
2260
|
+
for record in records:
|
|
2261
|
+
lines += view.feed.record(record)
|
|
2262
|
+
view.write(lines, follow=True if initial else None)
|
|
2263
|
+
view.show_running()
|
|
2264
|
+
|
|
2265
|
+
def action_cycle_mode(self) -> None:
|
|
2266
|
+
registry = self.conversation.registry
|
|
2267
|
+
if registry is None or self._approval_request or self.query_one("#model-panel").display:
|
|
2268
|
+
return
|
|
2269
|
+
if self._generating:
|
|
2270
|
+
self._hint_until = monotonic() + 2
|
|
2271
|
+
self._status("运行中不能切换权限档位 · Esc 取消")
|
|
2272
|
+
return
|
|
2273
|
+
self.conversation.set_permission_mode(MODE_CYCLE[registry.permissions.mode])
|
|
2274
|
+
self._status("")
|
|
2275
|
+
|
|
2276
|
+
def action_cancel(self) -> None:
|
|
2277
|
+
if self._approval_request:
|
|
2278
|
+
self._answer_approval("deny")
|
|
2279
|
+
return
|
|
2280
|
+
if self.query_one("#member-view").display: # Back to the panel.
|
|
2281
|
+
self._close_view()
|
|
2282
|
+
return
|
|
2283
|
+
if self.query_one("#member-panel", MemberPanel).expanded:
|
|
2284
|
+
self._collapse_members()
|
|
2285
|
+
return
|
|
2286
|
+
if self.query_one("#model-panel").display:
|
|
2287
|
+
cancel = self._on_cancel
|
|
2288
|
+
self._close_model_panel()
|
|
2289
|
+
if cancel is not None:
|
|
2290
|
+
self.call_later(cancel)
|
|
2291
|
+
return
|
|
2292
|
+
if self._hint_choices:
|
|
2293
|
+
self._dismissed_prompt = self.query_one("#prompt", PromptArea).value
|
|
2294
|
+
self._refresh_hints(self._dismissed_prompt)
|
|
2295
|
+
return
|
|
2296
|
+
queued = self._take_back_queued()
|
|
2297
|
+
if queued:
|
|
2298
|
+
# The first Esc only withdraws queued messages; the next one cancels the task.
|
|
2299
|
+
self.query_one("#prompt", PromptArea).restore_queued(queued)
|
|
2300
|
+
self._hint_until = monotonic() + 2
|
|
2301
|
+
self._status("已取回排队消息,任务继续 · 再按 Esc 取消任务")
|
|
2302
|
+
return
|
|
2303
|
+
self.action_cancel_task()
|
|
2304
|
+
|
|
2305
|
+
def action_cancel_task(self) -> None:
|
|
2306
|
+
if self._generating and self._reply_task and not self._reply_task.done():
|
|
2307
|
+
if not self._reply_task.cancelling():
|
|
2308
|
+
self._reply_task.cancel()
|
|
2309
|
+
|
|
2310
|
+
async def on_unmount(self) -> None:
|
|
2311
|
+
self._closing = True
|
|
2312
|
+
if self._members_task is not None and not self._members_task.done():
|
|
2313
|
+
self._members_task.cancel()
|
|
2314
|
+
if self.conversation.registry:
|
|
2315
|
+
self.conversation.registry.permissions.interactive = False
|
|
2316
|
+
if self._startup_task and not self._startup_task.done():
|
|
2317
|
+
self._startup_task.cancel()
|
|
2318
|
+
with suppress(asyncio.CancelledError):
|
|
2319
|
+
await self._startup_task
|
|
2320
|
+
if self._reply_task and not self._reply_task.done():
|
|
2321
|
+
if not self._reply_task.cancelling():
|
|
2322
|
+
self._reply_task.cancel()
|
|
2323
|
+
with suppress(asyncio.CancelledError):
|
|
2324
|
+
await self._reply_task
|
|
2325
|
+
await self.conversation.close()
|