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.
Files changed (91) hide show
  1. mosscode/__init__.py +10 -0
  2. mosscode/__main__.py +3 -0
  3. mosscode/agent.py +486 -0
  4. mosscode/agents/__init__.py +249 -0
  5. mosscode/agents/builtin/explore.md +16 -0
  6. mosscode/agents/builtin/general.md +12 -0
  7. mosscode/agents/builtin/plan.md +16 -0
  8. mosscode/agents/runtime.py +685 -0
  9. mosscode/agents/tasks.py +193 -0
  10. mosscode/appearance.py +184 -0
  11. mosscode/builtin_commands.py +1363 -0
  12. mosscode/cli.py +547 -0
  13. mosscode/commands.py +254 -0
  14. mosscode/compaction.py +259 -0
  15. mosscode/config.py +337 -0
  16. mosscode/context.py +102 -0
  17. mosscode/conversation.py +1046 -0
  18. mosscode/coordinator.py +54 -0
  19. mosscode/doctor.py +303 -0
  20. mosscode/hooks/__init__.py +22 -0
  21. mosscode/hooks/actions.py +151 -0
  22. mosscode/hooks/conditions.py +170 -0
  23. mosscode/hooks/config.py +404 -0
  24. mosscode/hooks/engine.py +401 -0
  25. mosscode/hooks/events.py +48 -0
  26. mosscode/launcher.py +177 -0
  27. mosscode/mcp_client.py +460 -0
  28. mosscode/mcp_config.py +216 -0
  29. mosscode/member_ui.py +289 -0
  30. mosscode/memory.py +291 -0
  31. mosscode/memory_extract.py +245 -0
  32. mosscode/messages.py +216 -0
  33. mosscode/paths.py +31 -0
  34. mosscode/permissions.py +733 -0
  35. mosscode/prompts.py +483 -0
  36. mosscode/providers.py +587 -0
  37. mosscode/scrollback.py +106 -0
  38. mosscode/security.py +282 -0
  39. mosscode/serialization.py +121 -0
  40. mosscode/session.py +563 -0
  41. mosscode/setup.py +248 -0
  42. mosscode/skills/__init__.py +455 -0
  43. mosscode/skills/builtin/commit/SKILL.md +15 -0
  44. mosscode/skills/builtin/review/SKILL.md +15 -0
  45. mosscode/skills/builtin/test/SKILL.md +13 -0
  46. mosscode/skills/builtin/test/scripts/detect.py +96 -0
  47. mosscode/skills/builtin/test/tool.json +9 -0
  48. mosscode/teams/__init__.py +216 -0
  49. mosscode/teams/backends.py +259 -0
  50. mosscode/teams/board.py +230 -0
  51. mosscode/teams/lock.py +173 -0
  52. mosscode/teams/mailbox.py +172 -0
  53. mosscode/teams/member.py +427 -0
  54. mosscode/teams/merge.py +207 -0
  55. mosscode/teams/pane.py +20 -0
  56. mosscode/teams/pane_app.py +167 -0
  57. mosscode/teams/process.py +371 -0
  58. mosscode/teams/runtime.py +1355 -0
  59. mosscode/teams/view.py +182 -0
  60. mosscode/tool_budget.py +86 -0
  61. mosscode/tools/__init__.py +38 -0
  62. mosscode/tools/agent.py +127 -0
  63. mosscode/tools/base.py +384 -0
  64. mosscode/tools/diffing.py +16 -0
  65. mosscode/tools/execution.py +99 -0
  66. mosscode/tools/files.py +287 -0
  67. mosscode/tools/memory.py +65 -0
  68. mosscode/tools/plan.py +30 -0
  69. mosscode/tools/process.py +216 -0
  70. mosscode/tools/search.py +229 -0
  71. mosscode/tools/search_worker.py +243 -0
  72. mosscode/tools/shell.py +121 -0
  73. mosscode/tools/skill.py +118 -0
  74. mosscode/tools/team.py +447 -0
  75. mosscode/tools/windows_job.py +75 -0
  76. mosscode/tui.py +2325 -0
  77. mosscode/usage.py +68 -0
  78. mosscode/windows_inline.py +148 -0
  79. mosscode/worktree/__init__.py +29 -0
  80. mosscode/worktree/changes.py +59 -0
  81. mosscode/worktree/git.py +125 -0
  82. mosscode/worktree/manager.py +462 -0
  83. mosscode/worktree/meta.py +82 -0
  84. mosscode/worktree/names.py +52 -0
  85. mosscode/worktree/setup.py +119 -0
  86. mosscode-0.1.0.dist-info/METADATA +92 -0
  87. mosscode-0.1.0.dist-info/RECORD +91 -0
  88. mosscode-0.1.0.dist-info/WHEEL +5 -0
  89. mosscode-0.1.0.dist-info/entry_points.txt +3 -0
  90. mosscode-0.1.0.dist-info/licenses/LICENSE +21 -0
  91. 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()