mindcode 0.2.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 (116) hide show
  1. mindcode/__init__.py +5 -0
  2. mindcode/__main__.py +4 -0
  3. mindcode/__pycache__/__init__.cpython-313.pyc +0 -0
  4. mindcode/__pycache__/__main__.cpython-313.pyc +0 -0
  5. mindcode/__pycache__/_version.cpython-313.pyc +0 -0
  6. mindcode/__pycache__/config.cpython-313.pyc +0 -0
  7. mindcode/__pycache__/policy.cpython-313.pyc +0 -0
  8. mindcode/__pycache__/remote.cpython-313.pyc +0 -0
  9. mindcode/__pycache__/render.cpython-313.pyc +0 -0
  10. mindcode/__pycache__/runtime.cpython-313.pyc +0 -0
  11. mindcode/__pycache__/tasking.cpython-313.pyc +0 -0
  12. mindcode/__pycache__/terminal_core.cpython-313.pyc +0 -0
  13. mindcode/_version.py +11 -0
  14. mindcode/approval.py +105 -0
  15. mindcode/bench/__init__.py +19 -0
  16. mindcode/bench/__pycache__/__init__.cpython-313.pyc +0 -0
  17. mindcode/bench/__pycache__/fake_tools.cpython-313.pyc +0 -0
  18. mindcode/bench/__pycache__/jsonutil.cpython-313.pyc +0 -0
  19. mindcode/bench/__pycache__/paths.cpython-313.pyc +0 -0
  20. mindcode/bench/__pycache__/predictions.cpython-313.pyc +0 -0
  21. mindcode/bench/__pycache__/report.cpython-313.pyc +0 -0
  22. mindcode/bench/__pycache__/runner.cpython-313.pyc +0 -0
  23. mindcode/bench/__pycache__/schema.cpython-313.pyc +0 -0
  24. mindcode/bench/__pycache__/trace.cpython-313.pyc +0 -0
  25. mindcode/bench/__pycache__/workspace.cpython-313.pyc +0 -0
  26. mindcode/bench/adapters/__init__.py +22 -0
  27. mindcode/bench/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
  28. mindcode/bench/adapters/__pycache__/base.cpython-313.pyc +0 -0
  29. mindcode/bench/adapters/__pycache__/mini_bfcl.cpython-313.pyc +0 -0
  30. mindcode/bench/adapters/__pycache__/mini_gaia.cpython-313.pyc +0 -0
  31. mindcode/bench/adapters/__pycache__/mini_terminal.cpython-313.pyc +0 -0
  32. mindcode/bench/adapters/__pycache__/swe_bench.cpython-313.pyc +0 -0
  33. mindcode/bench/adapters/__pycache__/terminal_bench.cpython-313.pyc +0 -0
  34. mindcode/bench/adapters/base.py +45 -0
  35. mindcode/bench/adapters/mini_bfcl.py +18 -0
  36. mindcode/bench/adapters/mini_gaia.py +18 -0
  37. mindcode/bench/adapters/mini_terminal.py +18 -0
  38. mindcode/bench/adapters/swe_bench.py +86 -0
  39. mindcode/bench/adapters/terminal_bench.py +107 -0
  40. mindcode/bench/fake_tools.py +73 -0
  41. mindcode/bench/jsonutil.py +20 -0
  42. mindcode/bench/paths.py +10 -0
  43. mindcode/bench/predictions.py +65 -0
  44. mindcode/bench/report.py +48 -0
  45. mindcode/bench/runner.py +250 -0
  46. mindcode/bench/schema.py +78 -0
  47. mindcode/bench/scorers/__pycache__/base.cpython-313.pyc +0 -0
  48. mindcode/bench/scorers/__pycache__/composite.cpython-313.pyc +0 -0
  49. mindcode/bench/scorers/__pycache__/exact.cpython-313.pyc +0 -0
  50. mindcode/bench/scorers/__pycache__/json_call.cpython-313.pyc +0 -0
  51. mindcode/bench/scorers/__pycache__/swe.cpython-313.pyc +0 -0
  52. mindcode/bench/scorers/__pycache__/terminal.cpython-313.pyc +0 -0
  53. mindcode/bench/scorers/__pycache__/terminal_bench.cpython-313.pyc +0 -0
  54. mindcode/bench/scorers/base.py +22 -0
  55. mindcode/bench/scorers/composite.py +57 -0
  56. mindcode/bench/scorers/exact.py +30 -0
  57. mindcode/bench/scorers/json_call.py +44 -0
  58. mindcode/bench/scorers/swe.py +29 -0
  59. mindcode/bench/scorers/terminal.py +43 -0
  60. mindcode/bench/scorers/terminal_bench.py +25 -0
  61. mindcode/bench/trace.py +34 -0
  62. mindcode/bench/workspace.py +127 -0
  63. mindcode/cli/__init__.py +112 -0
  64. mindcode/cli/__pycache__/__init__.cpython-313.pyc +0 -0
  65. mindcode/cli/__pycache__/_shared.cpython-313.pyc +0 -0
  66. mindcode/cli/_shared.py +43 -0
  67. mindcode/cli/commands/__init__.py +1 -0
  68. mindcode/cli/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  69. mindcode/cli/commands/__pycache__/bench.cpython-313.pyc +0 -0
  70. mindcode/cli/commands/__pycache__/chat.cpython-313.pyc +0 -0
  71. mindcode/cli/commands/__pycache__/config_cmd.cpython-313.pyc +0 -0
  72. mindcode/cli/commands/__pycache__/remote.cpython-313.pyc +0 -0
  73. mindcode/cli/commands/__pycache__/shell.cpython-313.pyc +0 -0
  74. mindcode/cli/commands/__pycache__/status.cpython-313.pyc +0 -0
  75. mindcode/cli/commands/__pycache__/task.cpython-313.pyc +0 -0
  76. mindcode/cli/commands/__pycache__/terminal.cpython-313.pyc +0 -0
  77. mindcode/cli/commands/bench.py +365 -0
  78. mindcode/cli/commands/chat.py +86 -0
  79. mindcode/cli/commands/config_cmd.py +197 -0
  80. mindcode/cli/commands/remote.py +146 -0
  81. mindcode/cli/commands/shell.py +108 -0
  82. mindcode/cli/commands/status.py +37 -0
  83. mindcode/cli/commands/task.py +417 -0
  84. mindcode/cli/commands/terminal.py +124 -0
  85. mindcode/cli/shell/__init__.py +5 -0
  86. mindcode/cli/shell/__pycache__/__init__.cpython-313.pyc +0 -0
  87. mindcode/cli/shell/__pycache__/completion.cpython-313.pyc +0 -0
  88. mindcode/cli/shell/__pycache__/menu.cpython-313.pyc +0 -0
  89. mindcode/cli/shell/__pycache__/repl.cpython-313.pyc +0 -0
  90. mindcode/cli/shell/__pycache__/slash.cpython-313.pyc +0 -0
  91. mindcode/cli/shell/__pycache__/startup.cpython-313.pyc +0 -0
  92. mindcode/cli/shell/completion.py +72 -0
  93. mindcode/cli/shell/menu.py +88 -0
  94. mindcode/cli/shell/repl.py +597 -0
  95. mindcode/cli/shell/slash.py +480 -0
  96. mindcode/cli/shell/startup.py +55 -0
  97. mindcode/cli/shell/tui.py +897 -0
  98. mindcode/config.py +173 -0
  99. mindcode/mcp.py +205 -0
  100. mindcode/policy.py +173 -0
  101. mindcode/remote.py +216 -0
  102. mindcode/render.py +458 -0
  103. mindcode/runtime.py +541 -0
  104. mindcode/skills.py +154 -0
  105. mindcode/subagents.py +43 -0
  106. mindcode/tasking.py +281 -0
  107. mindcode/terminal/__init__.py +1 -0
  108. mindcode/terminal/__main__.py +17 -0
  109. mindcode/terminal/__pycache__/__init__.cpython-313.pyc +0 -0
  110. mindcode/terminal/__pycache__/__main__.cpython-313.pyc +0 -0
  111. mindcode/terminal_core.py +264 -0
  112. mindcode-0.2.0.dist-info/METADATA +244 -0
  113. mindcode-0.2.0.dist-info/RECORD +116 -0
  114. mindcode-0.2.0.dist-info/WHEEL +5 -0
  115. mindcode-0.2.0.dist-info/entry_points.txt +2 -0
  116. mindcode-0.2.0.dist-info/top_level.txt +1 -0
mindcode/remote.py ADDED
@@ -0,0 +1,216 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import shlex
7
+ import subprocess
8
+ import time
9
+ from dataclasses import asdict, dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .config import CONFIG_DIR
14
+
15
+
16
+ REMOTES_FILE = CONFIG_DIR / "remotes.json"
17
+ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
18
+
19
+
20
+ @dataclass
21
+ class RemoteConfig:
22
+ name: str
23
+ host: str
24
+ workdir: str
25
+ port: int = 22
26
+ identity_file: str | None = None
27
+ env: dict[str, str] = field(default_factory=dict)
28
+
29
+ @classmethod
30
+ def from_dict(cls, name: str, data: dict[str, Any]) -> "RemoteConfig":
31
+ env = data.get("env", {})
32
+ if not isinstance(env, dict):
33
+ env = {}
34
+ return cls(
35
+ name=name,
36
+ host=str(data.get("host", "")),
37
+ port=int(data.get("port", 22) or 22),
38
+ workdir=str(data.get("workdir", "")),
39
+ identity_file=(
40
+ str(data["identity_file"]) if data.get("identity_file") else None
41
+ ),
42
+ env={str(key): str(value) for key, value in env.items()},
43
+ )
44
+
45
+ def validate(self) -> None:
46
+ if not self.name:
47
+ raise ValueError("remote name is required")
48
+ if not self.host:
49
+ raise ValueError("remote host is required")
50
+ if self.host.startswith("-") or any(
51
+ character.isspace() for character in self.host
52
+ ):
53
+ raise ValueError("remote host must not contain options or whitespace")
54
+ if not self.workdir:
55
+ raise ValueError("remote workdir is required")
56
+ if self.port <= 0:
57
+ raise ValueError("remote port must be positive")
58
+ for key in self.env:
59
+ if not _ENV_NAME_RE.match(key):
60
+ raise ValueError(f"invalid environment variable name: {key}")
61
+
62
+
63
+ class RemoteStore:
64
+ def __init__(self, path: Path | None = None):
65
+ self.path = path or REMOTES_FILE
66
+
67
+ def load_all(self) -> dict[str, RemoteConfig]:
68
+ if not self.path.exists():
69
+ return {}
70
+ data = json.loads(self.path.read_text(encoding="utf-8"))
71
+ if not isinstance(data, dict):
72
+ return {}
73
+ remotes: dict[str, RemoteConfig] = {}
74
+ for name, raw in data.items():
75
+ if isinstance(raw, dict):
76
+ remotes[str(name)] = RemoteConfig.from_dict(str(name), raw)
77
+ return remotes
78
+
79
+ def save_all(self, remotes: dict[str, RemoteConfig]) -> None:
80
+ self.path.parent.mkdir(parents=True, exist_ok=True)
81
+ data = {
82
+ name: _config_payload(config)
83
+ for name, config in sorted(remotes.items())
84
+ }
85
+ tmp = self.path.with_suffix(".tmp")
86
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
87
+ os.chmod(tmp, 0o600)
88
+ tmp.replace(self.path)
89
+
90
+ def get(self, name: str) -> RemoteConfig:
91
+ remotes = self.load_all()
92
+ try:
93
+ return remotes[name]
94
+ except KeyError as exc:
95
+ raise KeyError(f"unknown remote: {name}") from exc
96
+
97
+ def add(self, config: RemoteConfig) -> None:
98
+ config.validate()
99
+ remotes = self.load_all()
100
+ remotes[config.name] = config
101
+ self.save_all(remotes)
102
+
103
+ def remove(self, name: str) -> bool:
104
+ remotes = self.load_all()
105
+ if name not in remotes:
106
+ return False
107
+ del remotes[name]
108
+ self.save_all(remotes)
109
+ return True
110
+
111
+
112
+ @dataclass
113
+ class RemoteResult:
114
+ argv: list[str]
115
+ exit_code: int
116
+ stdout: str
117
+ stderr: str
118
+ duration_s: float
119
+
120
+
121
+ class SSHCommandBuilder:
122
+ def build_ssh_argv(self, config: RemoteConfig, remote_command: str) -> list[str]:
123
+ config.validate()
124
+ argv = [
125
+ "ssh",
126
+ "-p",
127
+ str(config.port),
128
+ "-o",
129
+ "StrictHostKeyChecking=accept-new",
130
+ ]
131
+ if config.identity_file:
132
+ argv.extend(["-i", str(Path(config.identity_file).expanduser())])
133
+ argv.append(config.host)
134
+ argv.append(self.build_remote_shell_command(config, remote_command))
135
+ return argv
136
+
137
+ def build_remote_shell_command(
138
+ self,
139
+ config: RemoteConfig,
140
+ remote_command: str,
141
+ ) -> str:
142
+ config.validate()
143
+ parts = [f"cd {shlex.quote(config.workdir)}"]
144
+ if config.env:
145
+ env_parts = [
146
+ f"{key}={shlex.quote(value)}" for key, value in sorted(config.env.items())
147
+ ]
148
+ parts.append("env " + " ".join(env_parts) + f" {remote_command}")
149
+ else:
150
+ parts.append(remote_command)
151
+ return " && ".join(parts)
152
+
153
+ def build_tail_command(
154
+ self,
155
+ path: str,
156
+ *,
157
+ lines: int = 80,
158
+ follow: bool = False,
159
+ ) -> str:
160
+ if lines <= 0:
161
+ raise ValueError("lines must be positive")
162
+ flags = ["tail", "-n", str(lines)]
163
+ if follow:
164
+ flags.append("-f")
165
+ flags.extend(["--", shlex.quote(path)])
166
+ return " ".join(flags)
167
+
168
+
169
+ class RemoteExecutor:
170
+ def __init__(self, builder: SSHCommandBuilder | None = None):
171
+ self.builder = builder or SSHCommandBuilder()
172
+
173
+ def execute(
174
+ self,
175
+ config: RemoteConfig,
176
+ command: str,
177
+ *,
178
+ timeout_s: float | None = None,
179
+ ) -> RemoteResult:
180
+ argv = self.builder.build_ssh_argv(config, command)
181
+ started = time.monotonic()
182
+ try:
183
+ process = subprocess.run(
184
+ argv,
185
+ text=True,
186
+ capture_output=True,
187
+ timeout=timeout_s,
188
+ check=False,
189
+ )
190
+ except subprocess.TimeoutExpired as exc:
191
+ duration = time.monotonic() - started
192
+ return RemoteResult(
193
+ argv=argv,
194
+ exit_code=124,
195
+ stdout=exc.stdout or "",
196
+ stderr=exc.stderr or f"remote command timed out after {timeout_s}s",
197
+ duration_s=duration,
198
+ )
199
+ duration = time.monotonic() - started
200
+ return RemoteResult(
201
+ argv=argv,
202
+ exit_code=process.returncode,
203
+ stdout=process.stdout,
204
+ stderr=process.stderr,
205
+ duration_s=duration,
206
+ )
207
+
208
+
209
+ def _config_payload(config: RemoteConfig) -> dict[str, Any]:
210
+ payload = asdict(config)
211
+ payload.pop("name", None)
212
+ return payload
213
+
214
+
215
+ def mask_env(env: dict[str, str]) -> dict[str, str]:
216
+ return {key: "***" for key in env}
mindcode/render.py ADDED
@@ -0,0 +1,458 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from mindagent.core import AgentEvent, EventType
7
+
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+ from rich.text import Text
12
+
13
+
14
+ class RichRenderer:
15
+ """EventHandler implementation that renders agent events with rich.
16
+
17
+ Tracks a `_streaming` flag so TEXT_DELTA and tool-call events can flush
18
+ each other's line breaks correctly.
19
+ """
20
+
21
+ def __init__(self, console: Console | None = None) -> None:
22
+ self.console = console or Console()
23
+ self._streaming = False
24
+ self._did_stream = False
25
+ self.last_diff: str | None = None
26
+ self.last_edit_path: str | None = None
27
+ self._transcript: Any | None = None
28
+ self.total_tokens: int | None = None
29
+
30
+ def attach_transcript(self, transcript: Any) -> None:
31
+ """Send assistant Markdown into the shell TUI when one is active."""
32
+ self._transcript = transcript
33
+
34
+ def detach_transcript(self) -> None:
35
+ self._transcript = None
36
+
37
+ def reset(self) -> None:
38
+ self._flush_stream()
39
+ self._streaming = False
40
+ self._did_stream = False
41
+
42
+ async def __call__(self, event: AgentEvent) -> None:
43
+ handler = self._dispatch.get(event.event_type)
44
+ if handler is not None:
45
+ await handler(self, event)
46
+
47
+ async def _on_text_delta(self, event: AgentEvent) -> None:
48
+ if self._transcript is not None:
49
+ self._streaming = True
50
+ self._did_stream = True
51
+ self._transcript.append_delta(event.payload.get("delta", ""))
52
+ return
53
+ if not self._streaming:
54
+ self.console.print()
55
+ self.console.print("[bold cyan]assistant›[/bold cyan]", end=" ")
56
+ self._streaming = True
57
+ self._did_stream = True
58
+ delta = event.payload.get("delta", "")
59
+ self.console.out(delta, end="", highlight=False)
60
+
61
+ async def _on_action_started(self, event: AgentEvent) -> None:
62
+ self._flush_stream()
63
+ name = event.payload.get("action_name", "?")
64
+ args = event.payload.get("arguments", {}) or {}
65
+ if self._transcript is not None:
66
+ label, detail = _tui_tool_summary(name, args)
67
+ self._transcript.add_tool(label, detail)
68
+ return
69
+ risk = event.payload.get("risk", "")
70
+ risk_tag = f" [dim yellow][{risk}][/dim yellow]" if risk else ""
71
+
72
+ if name == "file_edit":
73
+ path = args.get("path", "?")
74
+ self.console.print(
75
+ f" [bold blue]›[/bold blue] file_edit"
76
+ f" [cyan]{path}[/cyan]{risk_tag}"
77
+ )
78
+ elif name == "file_write":
79
+ path = args.get("path", "?")
80
+ self.console.print(
81
+ f" [bold blue]›[/bold blue] file_write"
82
+ f" [cyan]{path}[/cyan]{risk_tag}"
83
+ )
84
+ elif name == "exec_command":
85
+ argv = args.get("argv") or args.get("command") or []
86
+ if isinstance(argv, str):
87
+ # 模型把 command 传成了字符串(应为数组):整体展示,标注类型异常,
88
+ # 不要逐字符迭代(否则渲染成 "[ ' f i n d ' ...")。
89
+ preview = argv
90
+ type_note = " [dim red](参数应为数组)[/dim red]"
91
+ else:
92
+ preview = " ".join(_quote(a) for a in argv)
93
+ type_note = ""
94
+ if len(preview) > 120:
95
+ preview = preview[:117] + "..."
96
+ self.console.print(
97
+ f" [bold blue]›[/bold blue] exec [green]{preview}[/green]"
98
+ f"{type_note}{risk_tag}"
99
+ )
100
+ elif name == "file_read":
101
+ path = args.get("path", "?")
102
+ self.console.print(
103
+ f" [bold blue]›[/bold blue] file_read [cyan]{path}[/cyan]"
104
+ )
105
+ else:
106
+ short = json.dumps(args, ensure_ascii=False)
107
+ if len(short) > 100:
108
+ short = short[:97] + "..."
109
+ self.console.print(
110
+ f" [bold blue]›[/bold blue] [dim]{name}[/dim] {short}{risk_tag}"
111
+ )
112
+
113
+ async def _on_observation(self, event: AgentEvent) -> None:
114
+ name = event.payload.get("action_name", "?")
115
+ ok = event.payload.get("ok", False)
116
+ result = event.payload.get("result")
117
+ error = event.payload.get("error")
118
+
119
+ if self._transcript is not None:
120
+ self._render_tui_observation(name, ok, result, error)
121
+ return
122
+
123
+ if name in {"file_edit", "file_write"} and ok:
124
+ diff = result.get("mindcode_diff", "") if isinstance(result, dict) else ""
125
+ path = result.get("path", "") if isinstance(result, dict) else ""
126
+ if diff:
127
+ self.last_diff = diff
128
+ self.last_edit_path = path
129
+ self._render_diff_panel(diff, path)
130
+ else:
131
+ self.console.print(
132
+ f" [green]✓[/green] {name} [cyan]{path}[/cyan]"
133
+ " [dim](无变化)[/dim]"
134
+ )
135
+ return
136
+
137
+ if name == "exec_command":
138
+ exit_code, timed_out, succeeded, reason = _command_status(
139
+ ok, result, error
140
+ )
141
+ if succeeded:
142
+ stdout = (
143
+ result.get("stdout", "") if isinstance(result, dict) else ""
144
+ )
145
+ preview = _first_line(stdout)
146
+ if preview:
147
+ self.console.print(
148
+ f" [green]✓[/green] exit=0 "
149
+ f"[dim]{preview}[/dim]"
150
+ )
151
+ else:
152
+ self.console.print(" [green]✓[/green] exit=0")
153
+ else:
154
+ detail = _format_command_failure(
155
+ exit_code,
156
+ timed_out,
157
+ reason,
158
+ exit_separator="=",
159
+ )
160
+ self.console.print(f" [red]✗[/red] {detail}")
161
+ stderr = (
162
+ result.get("stderr", "") if isinstance(result, dict) else ""
163
+ )
164
+ if stderr:
165
+ self._render_text_panel(stderr, "stderr", border="red")
166
+ return
167
+
168
+ status = "[green]✓[/green]" if ok else "[red]✗[/red]"
169
+ if ok:
170
+ self.console.print(f" {status} [dim]{name}[/dim]")
171
+ else:
172
+ msg = error or "failed"
173
+ self.console.print(f" {status} [dim]{name}:[/dim] {msg}")
174
+
175
+ async def _on_decision(self, event: AgentEvent) -> None:
176
+ kind = event.payload.get("decision_kind", "")
177
+ summary = event.payload.get("reasoning_summary") or ""
178
+ self._record_usage(event.payload.get("usage"))
179
+ if not summary:
180
+ return
181
+ preview = summary if len(summary) <= 80 else summary[:77] + "..."
182
+ if self._transcript is not None:
183
+ self._transcript.add_thinking(preview)
184
+ return
185
+ self.console.print(
186
+ f" [dim italic][{kind}][/dim italic] [dim]{preview}[/dim]"
187
+ )
188
+
189
+ async def _on_error(self, event: AgentEvent) -> None:
190
+ self._flush_stream()
191
+ error = event.payload.get("error", "unknown error")
192
+ info = event.payload.get("error_info") or {}
193
+ body = error
194
+ if isinstance(info, dict):
195
+ code = info.get("code")
196
+ if code:
197
+ body = f"[{code}] {error}"
198
+ self.console.print(
199
+ Panel(
200
+ Text(body, style="red"),
201
+ border_style="red",
202
+ title="error",
203
+ title_align="left",
204
+ )
205
+ )
206
+
207
+ async def _on_completion_rejected(self, event: AgentEvent) -> None:
208
+ reasons = event.payload.get("reasons") or []
209
+ text = "\n".join(f"• {r}" for r in reasons)
210
+ self.console.print(
211
+ Panel(
212
+ Text(text or "完成声明被拒绝", style="yellow"),
213
+ border_style="yellow",
214
+ title="completion rejected",
215
+ title_align="left",
216
+ )
217
+ )
218
+
219
+ def _flush_stream(self) -> None:
220
+ if self._transcript is not None:
221
+ if self._streaming:
222
+ self._transcript.finish_stream()
223
+ self._streaming = False
224
+ return
225
+ if self._streaming:
226
+ self.console.print()
227
+ self._streaming = False
228
+
229
+ def render_final_answer(self, content: str) -> None:
230
+ """Render a non-streamed final answer in either shell presentation."""
231
+ if self._transcript is not None:
232
+ self._transcript.add_assistant(content)
233
+ return
234
+ self.console.print(f"\n[bold cyan]answer›[/bold cyan] {content}")
235
+
236
+ def _render_tui_observation(
237
+ self,
238
+ name: str,
239
+ ok: bool,
240
+ result: Any,
241
+ error: Any,
242
+ ) -> None:
243
+ """Update concise tool rows instead of exposing internal tool names."""
244
+ assert self._transcript is not None
245
+ if name in {"file_edit", "file_write"}:
246
+ diff = result.get("mindcode_diff", "") if isinstance(result, dict) else ""
247
+ path = result.get("path", "") if isinstance(result, dict) else ""
248
+ if ok and diff:
249
+ self.last_diff = diff
250
+ self.last_edit_path = path
251
+ self._transcript.finish_tool(True, f"updated {path}")
252
+ self._transcript.add_tool_detail(f"```diff\n{diff}\n```")
253
+ elif ok:
254
+ self._transcript.finish_tool(True, f"no changes {path}")
255
+ else:
256
+ self._transcript.finish_tool(False, str(error or "edit failed"))
257
+ return
258
+
259
+ if name == "exec_command":
260
+ exit_code, timed_out, succeeded, reason = _command_status(
261
+ ok, result, error
262
+ )
263
+ if succeeded:
264
+ stdout = result.get("stdout", "") if isinstance(result, dict) else ""
265
+ preview = _first_line(stdout)
266
+ detail = "exit 0"
267
+ if preview:
268
+ detail += f" · {preview}"
269
+ self._transcript.finish_tool(True, detail)
270
+ else:
271
+ self._transcript.finish_tool(
272
+ False,
273
+ _format_command_failure(
274
+ exit_code,
275
+ timed_out,
276
+ reason,
277
+ exit_separator=" ",
278
+ ),
279
+ )
280
+ stderr = result.get("stderr", "") if isinstance(result, dict) else ""
281
+ if stderr:
282
+ self._transcript.add_tool_detail(f"```text\n{stderr}\n```")
283
+ return
284
+
285
+ self._transcript.finish_tool(
286
+ bool(ok),
287
+ "complete" if ok else str(error or "failed"),
288
+ )
289
+
290
+ def _record_usage(self, usage: Any) -> None:
291
+ if not isinstance(usage, dict):
292
+ return
293
+ total = usage.get("total_tokens")
294
+ if not isinstance(total, int) or total < 0:
295
+ return
296
+ self.total_tokens = (self.total_tokens or 0) + total
297
+
298
+ def _render_diff_panel(self, diff_text: str, path: str) -> None:
299
+ syntax = Syntax(
300
+ diff_text,
301
+ "diff",
302
+ theme="ansi_dark",
303
+ line_numbers=False,
304
+ word_wrap=True,
305
+ background_color="default",
306
+ )
307
+ self.console.print(
308
+ Panel(
309
+ syntax,
310
+ border_style="cyan",
311
+ title=f"diff: {path}",
312
+ title_align="left",
313
+ )
314
+ )
315
+
316
+ def _render_text_panel(
317
+ self,
318
+ text: str,
319
+ title: str,
320
+ *,
321
+ border: str = "dim",
322
+ ) -> None:
323
+ body = text if len(text) <= 2000 else text[:1997] + "..."
324
+ self.console.print(
325
+ Panel(
326
+ Text(body),
327
+ border_style=border,
328
+ title=title,
329
+ title_align="left",
330
+ )
331
+ )
332
+
333
+ _dispatch = {
334
+ EventType.TEXT_DELTA: _on_text_delta,
335
+ EventType.ACTION_STARTED: _on_action_started,
336
+ EventType.OBSERVATION_CREATED: _on_observation,
337
+ EventType.DECISION_CREATED: _on_decision,
338
+ EventType.ERROR_CREATED: _on_error,
339
+ EventType.COMPLETION_REJECTED: _on_completion_rejected,
340
+ }
341
+
342
+
343
+ def _quote(arg: Any) -> str:
344
+ s = str(arg)
345
+ if any(c in s for c in {" ", "\t", "'", '"', "$"}):
346
+ return "'" + s.replace("'", "'\\''") + "'"
347
+ return s
348
+
349
+
350
+ def _first_line(text: str, *, limit: int = 100) -> str:
351
+ if not text:
352
+ return ""
353
+ line = text.splitlines()[0] if text.splitlines() else ""
354
+ return line if len(line) <= limit else line[: limit - 3] + "..."
355
+
356
+
357
+ def _command_status(
358
+ ok: bool,
359
+ result: Any,
360
+ error: Any,
361
+ ) -> tuple[int | None, bool, bool, str]:
362
+ """Return a user-facing status without inventing an exit code.
363
+
364
+ A missing or malformed ``exit_code`` means the command did not produce a
365
+ process result. It must not be rendered as ``exit None``; use the
366
+ observation error (when available) as the primary failure reason instead.
367
+ """
368
+
369
+ payload = result if isinstance(result, dict) else {}
370
+ raw_exit_code = payload.get("exit_code")
371
+ exit_code = (
372
+ raw_exit_code
373
+ if isinstance(raw_exit_code, int) and not isinstance(raw_exit_code, bool)
374
+ else None
375
+ )
376
+ timed_out = bool(payload.get("timed_out", False))
377
+
378
+ if ok and exit_code == 0 and not timed_out:
379
+ return exit_code, timed_out, True, ""
380
+
381
+ reason = _error_text(error)
382
+ if exit_code is not None and exit_code != 0:
383
+ return exit_code, timed_out, False, reason
384
+
385
+ if timed_out:
386
+ return exit_code, timed_out, False, reason or "timed out"
387
+
388
+ if not ok:
389
+ return (
390
+ exit_code,
391
+ timed_out,
392
+ False,
393
+ reason or "command failed",
394
+ )
395
+
396
+ return exit_code, timed_out, False, reason or "missing exit code"
397
+
398
+
399
+ def _format_command_failure(
400
+ exit_code: int | None,
401
+ timed_out: bool,
402
+ reason: str,
403
+ *,
404
+ exit_separator: str,
405
+ ) -> str:
406
+ """Format a failed command for one of the renderer presentations."""
407
+
408
+ if exit_code is not None and exit_code != 0:
409
+ detail = f"exit{exit_separator}{exit_code}"
410
+ if timed_out:
411
+ detail += " timeout" if exit_separator == "=" else " · timed out"
412
+ if reason:
413
+ detail += f" · {reason}"
414
+ return detail
415
+ if timed_out:
416
+ detail = "failed · timed out"
417
+ if reason and reason != "timed out":
418
+ detail += f" · {reason}"
419
+ return detail
420
+ return f"failed · {reason}"
421
+
422
+
423
+ def _error_text(error: Any) -> str:
424
+ if isinstance(error, str):
425
+ return error.strip()
426
+ if error is None:
427
+ return ""
428
+ return str(error).strip()
429
+
430
+
431
+ def _tui_tool_summary(name: str, args: Any) -> tuple[str, str]:
432
+ """Map runtime action names to compact, user-facing TUI labels."""
433
+ arguments = args if isinstance(args, dict) else {}
434
+ labels = {
435
+ "file_edit": "Edit",
436
+ "file_write": "Write",
437
+ "exec_command": "Command",
438
+ "file_read": "Read",
439
+ "memory_read": "Memory",
440
+ "delegate": "Delegate",
441
+ }
442
+ label = labels.get(name, name.replace("_", " ").title() or "Tool")
443
+
444
+ if name in {"file_edit", "file_write"}:
445
+ return label, str(arguments.get("path", ""))
446
+ if name == "exec_command":
447
+ command = arguments.get("argv") or arguments.get("command") or []
448
+ preview = command if isinstance(command, str) else " ".join(_quote(arg) for arg in command)
449
+ return label, _shorten(preview, 120)
450
+ if name == "file_read":
451
+ return label, str(arguments.get("path", ""))
452
+
453
+ detail = json.dumps(arguments, ensure_ascii=False)
454
+ return label, _shorten(detail, 100)
455
+
456
+
457
+ def _shorten(text: str, limit: int) -> str:
458
+ return text if len(text) <= limit else text[: limit - 3] + "..."