@openlucaskaka/kagent 0.1.7
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.
- package/README.md +353 -0
- package/npm/bin/kagent-serve.js +6 -0
- package/npm/bin/kagent.js +6 -0
- package/npm/lib/App.js +524 -0
- package/npm/lib/app-state.js +224 -0
- package/npm/lib/approval-choice.js +25 -0
- package/npm/lib/commands.js +59 -0
- package/npm/lib/editor.js +188 -0
- package/npm/lib/ink-runner.js +41 -0
- package/npm/lib/kagent-home.js +39 -0
- package/npm/lib/launcher.js +221 -0
- package/npm/lib/protocol.js +33 -0
- package/npm/lib/provider-setup.js +139 -0
- package/npm/lib/python-runner.js +892 -0
- package/npm/lib/runtime-client.js +390 -0
- package/npm/lib/terminal-input.js +127 -0
- package/npm/lib/terminal-text.js +19 -0
- package/npm/lib/terminal-width.js +14 -0
- package/npm/lib/transcript.js +227 -0
- package/npm/lib/ui-components.js +247 -0
- package/npm/lib/update-manager.js +334 -0
- package/package.json +39 -0
- package/pyproject.toml +55 -0
- package/src/kagent/__init__.py +90 -0
- package/src/kagent/cli/__init__.py +5 -0
- package/src/kagent/cli/__main__.py +6 -0
- package/src/kagent/cli/commands.py +112 -0
- package/src/kagent/cli/conversation.py +127 -0
- package/src/kagent/cli/interactive.py +841 -0
- package/src/kagent/cli/main.py +685 -0
- package/src/kagent/cli/memory.py +460 -0
- package/src/kagent/cli/pending_approval.py +169 -0
- package/src/kagent/cli/provider.py +27 -0
- package/src/kagent/cli/session_commands.py +401 -0
- package/src/kagent/cli/stdio_runtime.py +784 -0
- package/src/kagent/cli/trace.py +67 -0
- package/src/kagent/cli/ui.py +931 -0
- package/src/kagent/core/__init__.py +0 -0
- package/src/kagent/core/agent.py +296 -0
- package/src/kagent/core/faults.py +11 -0
- package/src/kagent/core/invariants.py +47 -0
- package/src/kagent/core/normalization.py +81 -0
- package/src/kagent/core/planning.py +48 -0
- package/src/kagent/core/state.py +73 -0
- package/src/kagent/core/summary.py +64 -0
- package/src/kagent/core/tools.py +196 -0
- package/src/kagent/core/trace.py +57 -0
- package/src/kagent/eval/__init__.py +11 -0
- package/src/kagent/eval/cases.py +229 -0
- package/src/kagent/eval/evaluator.py +216 -0
- package/src/kagent/integrations/__init__.py +3 -0
- package/src/kagent/integrations/audit.py +95 -0
- package/src/kagent/integrations/backends.py +131 -0
- package/src/kagent/integrations/memory.py +301 -0
- package/src/kagent/ops/__init__.py +0 -0
- package/src/kagent/ops/batch.py +156 -0
- package/src/kagent/ops/doctor.py +255 -0
- package/src/kagent/ops/metrics.py +214 -0
- package/src/kagent/ops/release_evidence.py +877 -0
- package/src/kagent/ops/release_manifest.py +142 -0
- package/src/kagent/ops/trace_replay.py +285 -0
- package/src/kagent/providers/__init__.py +7 -0
- package/src/kagent/providers/embeddings.py +187 -0
- package/src/kagent/providers/llm.py +770 -0
- package/src/kagent/py.typed +1 -0
- package/src/kagent/runtime/__init__.py +28 -0
- package/src/kagent/runtime/action_graph.py +543 -0
- package/src/kagent/runtime/agent.py +2089 -0
- package/src/kagent/runtime/approval.py +64 -0
- package/src/kagent/runtime/cancellation.py +64 -0
- package/src/kagent/runtime/checkpoint_state.py +146 -0
- package/src/kagent/runtime/checkpoint_storage.py +270 -0
- package/src/kagent/runtime/context.py +65 -0
- package/src/kagent/runtime/file_transaction.py +195 -0
- package/src/kagent/runtime/hooks.py +74 -0
- package/src/kagent/runtime/metadata.py +116 -0
- package/src/kagent/runtime/patch_checkpoints.py +385 -0
- package/src/kagent/runtime/policy.py +50 -0
- package/src/kagent/runtime/presentation.py +205 -0
- package/src/kagent/runtime/redaction.py +130 -0
- package/src/kagent/runtime/sandbox.py +331 -0
- package/src/kagent/runtime/skills.py +66 -0
- package/src/kagent/runtime/steering.py +56 -0
- package/src/kagent/runtime/steps.py +255 -0
- package/src/kagent/runtime/task_state.py +40 -0
- package/src/kagent/runtime/tools.py +3532 -0
- package/src/kagent/runtime/types.py +240 -0
- package/src/kagent/runtime/workspace.py +597 -0
- package/src/kagent/service/__init__.py +26 -0
- package/src/kagent/service/__main__.py +6 -0
- package/src/kagent/service/active_runs.py +178 -0
- package/src/kagent/service/cli.py +737 -0
- package/src/kagent/service/contract.py +2571 -0
- package/src/kagent/service/errors.py +71 -0
- package/src/kagent/service/idempotency.py +584 -0
- package/src/kagent/service/router.py +884 -0
- package/src/kagent/service/run.py +150 -0
- package/src/kagent/service/runtime.py +1915 -0
- package/src/kagent/service/runtime_approval.py +20 -0
- package/src/kagent/service/runtime_cancel.py +192 -0
- package/src/kagent/service/runtime_lifecycle.py +229 -0
- package/src/kagent/service/runtime_metadata.py +21 -0
- package/src/kagent/service/runtime_policy.py +115 -0
- package/src/kagent/service/runtime_recovery.py +573 -0
- package/src/kagent/service/runtime_resume.py +382 -0
- package/src/kagent/service/runtime_resume_claim.py +116 -0
- package/src/kagent/service/runtime_run.py +350 -0
- package/src/kagent/service/runtime_status.py +2007 -0
- package/src/kagent/service/safety.py +139 -0
- package/src/kagent/service/server.py +114 -0
- package/src/kagent/service/status.py +233 -0
- package/src/kagent/service/trace_store.py +322 -0
- package/src/kagent/service/transport.py +24 -0
- package/src/kagent/utils/__init__.py +0 -0
- package/src/kagent/utils/config_validation.py +21 -0
- package/src/kagent/utils/json_output.py +23 -0
- package/src/kagent/utils/paths.py +473 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
import unicodedata
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from kagent.cli.commands import runtime_interactive_commands
|
|
12
|
+
from kagent.cli.memory import RuntimeSessionMemory, coerce_runtime_session_memory
|
|
13
|
+
from kagent.utils.json_output import json_ready
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def runtime_ui_color_enabled() -> bool:
|
|
17
|
+
return (
|
|
18
|
+
sys.stdout.isatty()
|
|
19
|
+
and "NO_COLOR" not in os.environ
|
|
20
|
+
and os.environ.get("TERM", "") != "dumb"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def runtime_ready_message(*, color: bool = False) -> str:
|
|
25
|
+
subtitle = "local terminal agent"
|
|
26
|
+
product_line = "ask anything. approve actions when needed."
|
|
27
|
+
return "\n".join(
|
|
28
|
+
[
|
|
29
|
+
_color("kagent", "bold", enabled=color),
|
|
30
|
+
_dim(subtitle, enabled=color),
|
|
31
|
+
"",
|
|
32
|
+
_dim(product_line, enabled=color),
|
|
33
|
+
]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def runtime_prompt(*, color: bool = False) -> str:
|
|
38
|
+
if not color:
|
|
39
|
+
return "› "
|
|
40
|
+
return (
|
|
41
|
+
"\001\033[36m\002"
|
|
42
|
+
"› "
|
|
43
|
+
"\001\033[97m\002"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def runtime_prompt_reset(*, color: bool = False) -> str:
|
|
48
|
+
return "\033[0m" if color else ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def runtime_user_message_block(
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
color: bool = False,
|
|
55
|
+
width: int | None = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
text = " ".join(str(message).split())
|
|
58
|
+
line = f"› {text}" if text else "›"
|
|
59
|
+
if not color:
|
|
60
|
+
return line
|
|
61
|
+
return f"{_color('›', 'cyan', enabled=True)} {_color(text, 'white', enabled=True)}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def runtime_setup_message(*, config_path: str, color: bool = False) -> str:
|
|
65
|
+
return "\n".join(
|
|
66
|
+
[
|
|
67
|
+
_color("kagent setup", "bold", enabled=color),
|
|
68
|
+
" Configure your provider once.",
|
|
69
|
+
_dim("Choose a provider once, then kagent opens directly next time.", enabled=color),
|
|
70
|
+
"",
|
|
71
|
+
f"Config {config_path}",
|
|
72
|
+
]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def runtime_interactive_help() -> str:
|
|
77
|
+
commands = runtime_interactive_commands()
|
|
78
|
+
command_width = max(len(command.primary) for command in commands)
|
|
79
|
+
lines = ["kagent command palette"]
|
|
80
|
+
for section in ("Session", "Provider", "Output", "Debug"):
|
|
81
|
+
section_commands = [
|
|
82
|
+
command for command in commands if command.section == section
|
|
83
|
+
]
|
|
84
|
+
if not section_commands:
|
|
85
|
+
continue
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append(section)
|
|
88
|
+
for command in section_commands:
|
|
89
|
+
lines.append(
|
|
90
|
+
f" {command.primary.ljust(command_width)} {command.description}"
|
|
91
|
+
)
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_runtime_provider_config(provider: Any) -> str:
|
|
96
|
+
snapshot = _provider_redacted_snapshot(provider)
|
|
97
|
+
if not snapshot:
|
|
98
|
+
return "kagent provider\n provider inline/test\n api_key not configured"
|
|
99
|
+
provider_name = str(
|
|
100
|
+
snapshot.get("llm_provider_display_name")
|
|
101
|
+
or snapshot.get("llm_provider")
|
|
102
|
+
or "unknown"
|
|
103
|
+
)
|
|
104
|
+
base_url = str(snapshot.get("llm_base_url", "")).strip() or "-"
|
|
105
|
+
model = str(snapshot.get("llm_model", "")).strip() or "-"
|
|
106
|
+
api_key_state = (
|
|
107
|
+
"configured"
|
|
108
|
+
if str(snapshot.get("llm_api_key_configured", "")).lower() == "true"
|
|
109
|
+
else "not configured"
|
|
110
|
+
)
|
|
111
|
+
timeout = str(snapshot.get("llm_timeout_seconds", "")).strip()
|
|
112
|
+
retries = str(snapshot.get("llm_max_retries", "")).strip()
|
|
113
|
+
backoff = str(snapshot.get("llm_retry_backoff_seconds", "")).strip()
|
|
114
|
+
return "\n".join(
|
|
115
|
+
[
|
|
116
|
+
"kagent provider",
|
|
117
|
+
f" provider {provider_name}",
|
|
118
|
+
f" base_url {base_url}",
|
|
119
|
+
f" model {model}",
|
|
120
|
+
f" api_key {api_key_state}",
|
|
121
|
+
f" timeout {timeout}s" if timeout else " timeout -",
|
|
122
|
+
f" retries {retries}" if retries else " retries -",
|
|
123
|
+
f" backoff {backoff}s" if backoff else " backoff -",
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _provider_redacted_snapshot(provider: Any) -> dict[str, Any]:
|
|
129
|
+
config = getattr(provider, "config", None)
|
|
130
|
+
snapshot_fn = getattr(config, "redacted_snapshot", None)
|
|
131
|
+
if not callable(snapshot_fn):
|
|
132
|
+
return {}
|
|
133
|
+
snapshot = snapshot_fn()
|
|
134
|
+
return snapshot if isinstance(snapshot, dict) else {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def format_runtime_interactive_tools(tools: list[dict[str, Any]]) -> str:
|
|
138
|
+
rows = []
|
|
139
|
+
for tool in sorted(tools, key=lambda item: str(item.get("name", ""))):
|
|
140
|
+
name = str(tool.get("name", "")).strip()
|
|
141
|
+
if not name:
|
|
142
|
+
continue
|
|
143
|
+
if name in {"note"}:
|
|
144
|
+
continue
|
|
145
|
+
approval = str(tool.get("approval_required_by_default", "")).strip().lower()
|
|
146
|
+
access = "approval" if approval == "true" else "allowed"
|
|
147
|
+
description = _one_line_text(str(tool.get("description", "")).strip())
|
|
148
|
+
rows.append((name, access, description))
|
|
149
|
+
if not rows:
|
|
150
|
+
return "kagent actions\n no external actions registered"
|
|
151
|
+
name_width = max(len(name) for name, _access, _description in rows)
|
|
152
|
+
return "\n".join(
|
|
153
|
+
["kagent actions"]
|
|
154
|
+
+ [
|
|
155
|
+
f" {name.ljust(name_width)} {access.ljust(8)} {description}".rstrip()
|
|
156
|
+
for name, access, description in rows
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def format_runtime_interactive_status(
|
|
162
|
+
*,
|
|
163
|
+
cwd: str,
|
|
164
|
+
full_json_mode: bool,
|
|
165
|
+
session_memory: RuntimeSessionMemory | list[dict[str, str]],
|
|
166
|
+
last_payload: Any,
|
|
167
|
+
trace_dir: str = "",
|
|
168
|
+
) -> str:
|
|
169
|
+
memory = coerce_runtime_session_memory(session_memory)
|
|
170
|
+
memory_count = len(memory.turns)
|
|
171
|
+
memory_label = "turn" if memory_count == 1 else "turns"
|
|
172
|
+
compacted = f", {memory.compacted_turn_count} compacted" if memory.compacted_turn_count else ""
|
|
173
|
+
last_status = "-"
|
|
174
|
+
if isinstance(last_payload, dict):
|
|
175
|
+
last_status = str(last_payload.get("status", "")).strip() or "-"
|
|
176
|
+
return "\n".join(
|
|
177
|
+
[
|
|
178
|
+
"kagent session",
|
|
179
|
+
f" cwd {cwd}",
|
|
180
|
+
f" output {'full JSON' if full_json_mode else 'compact'}",
|
|
181
|
+
f" memory {memory_count} recent {memory_label}{compacted}",
|
|
182
|
+
f" last {last_status}",
|
|
183
|
+
f" trace {trace_dir or 'off'}",
|
|
184
|
+
]
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def format_runtime_interactive_doctor(
|
|
189
|
+
*,
|
|
190
|
+
cwd: str,
|
|
191
|
+
provider: Any,
|
|
192
|
+
session_memory_path: str,
|
|
193
|
+
history_path: str,
|
|
194
|
+
trace_dir: str,
|
|
195
|
+
line_editor: str,
|
|
196
|
+
) -> str:
|
|
197
|
+
provider_snapshot = _provider_redacted_snapshot(provider)
|
|
198
|
+
provider_name = str(
|
|
199
|
+
provider_snapshot.get("llm_provider_display_name")
|
|
200
|
+
or provider_snapshot.get("llm_provider")
|
|
201
|
+
or "inline/test"
|
|
202
|
+
)
|
|
203
|
+
model = str(provider_snapshot.get("llm_model", "")).strip() or "-"
|
|
204
|
+
base_url_state = (
|
|
205
|
+
"configured"
|
|
206
|
+
if str(provider_snapshot.get("llm_base_url", "")).strip()
|
|
207
|
+
else "not configured"
|
|
208
|
+
)
|
|
209
|
+
api_key_state = (
|
|
210
|
+
"configured"
|
|
211
|
+
if str(provider_snapshot.get("llm_api_key_configured", "")).lower() == "true"
|
|
212
|
+
else "not configured"
|
|
213
|
+
)
|
|
214
|
+
return "\n".join(
|
|
215
|
+
[
|
|
216
|
+
"kagent doctor",
|
|
217
|
+
f" cwd {cwd}",
|
|
218
|
+
f" provider {provider_name}",
|
|
219
|
+
f" model {model}",
|
|
220
|
+
f" base_url {base_url_state}",
|
|
221
|
+
f" api_key {api_key_state}",
|
|
222
|
+
f" memory {session_memory_path or 'off'}",
|
|
223
|
+
f" history {history_path or 'off'}",
|
|
224
|
+
f" line_editor {line_editor or '-'}",
|
|
225
|
+
f" trace {trace_dir or 'off'}",
|
|
226
|
+
]
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def format_runtime_session_memory(
|
|
231
|
+
session_memory: RuntimeSessionMemory | list[dict[str, str]],
|
|
232
|
+
) -> str:
|
|
233
|
+
memory = coerce_runtime_session_memory(session_memory)
|
|
234
|
+
if not memory:
|
|
235
|
+
return "Memory is empty."
|
|
236
|
+
lines = ["Memory"]
|
|
237
|
+
if memory.summary:
|
|
238
|
+
lines.append(" summary")
|
|
239
|
+
lines.extend(_indented_lines(memory.summary, prefix=" "))
|
|
240
|
+
if memory.facts:
|
|
241
|
+
lines.append(" facts")
|
|
242
|
+
lines.extend(f" - {fact}" for fact in memory.facts)
|
|
243
|
+
if memory.open_items:
|
|
244
|
+
lines.append(" open items")
|
|
245
|
+
lines.extend(f" - {item}" for item in memory.open_items)
|
|
246
|
+
if memory.compacted_turn_count:
|
|
247
|
+
lines.append(f" compacted turns {memory.compacted_turn_count}")
|
|
248
|
+
if memory.turns:
|
|
249
|
+
lines.append(" recent turns")
|
|
250
|
+
for index, turn in enumerate(memory.turns, start=1):
|
|
251
|
+
user = turn.get("user", "")
|
|
252
|
+
assistant = turn.get("assistant", "")
|
|
253
|
+
lines.append(f" {index}. user {user}")
|
|
254
|
+
if assistant:
|
|
255
|
+
lines.append(f" agent {assistant}")
|
|
256
|
+
return "\n".join(lines)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def format_runtime_notice(title: str, detail: str = "") -> str:
|
|
260
|
+
lines = [str(title).strip()]
|
|
261
|
+
detail_text = str(detail).strip()
|
|
262
|
+
if detail_text:
|
|
263
|
+
lines.extend(_indented_lines(detail_text, prefix=" "))
|
|
264
|
+
return "\n".join(lines)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def format_runtime_pending_approval_detail(pending: dict) -> str:
|
|
268
|
+
return "\n".join(
|
|
269
|
+
[
|
|
270
|
+
"Approval detail",
|
|
271
|
+
*_indented_lines(_format_pending_approval(pending), prefix=" "),
|
|
272
|
+
" reply y approve, n skip",
|
|
273
|
+
]
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def format_runtime_interactive_summary(payload: Any, *, color: bool = False) -> str:
|
|
278
|
+
if not isinstance(payload, dict):
|
|
279
|
+
return str(payload)
|
|
280
|
+
|
|
281
|
+
status = str(payload.get("status", "")).strip()
|
|
282
|
+
pending = payload.get("pending_approval")
|
|
283
|
+
if status == "requires_approval" and isinstance(pending, dict):
|
|
284
|
+
return _format_runtime_approval_summary(pending, color=color)
|
|
285
|
+
|
|
286
|
+
lines = [_format_run_status(payload, status, color=color)]
|
|
287
|
+
|
|
288
|
+
answer = (
|
|
289
|
+
""
|
|
290
|
+
if payload.get("answer_streamed") == "true"
|
|
291
|
+
else str(payload.get("answer", "")).strip()
|
|
292
|
+
)
|
|
293
|
+
if answer:
|
|
294
|
+
lines.append("")
|
|
295
|
+
lines.append(_dim("Answer", enabled=color))
|
|
296
|
+
lines.extend(_answer_lines(answer))
|
|
297
|
+
|
|
298
|
+
error_code = str(payload.get("error_code", "")).strip()
|
|
299
|
+
error = str(payload.get("error", "")).strip()
|
|
300
|
+
if error_code or error:
|
|
301
|
+
lines.append("")
|
|
302
|
+
lines.append(_color("Error", "red", enabled=color))
|
|
303
|
+
lines.extend(_indented_lines(join_non_empty([error_code, error], " "), prefix=" "))
|
|
304
|
+
|
|
305
|
+
visible_steps = visible_runtime_steps(payload.get("steps"))
|
|
306
|
+
if visible_steps:
|
|
307
|
+
lines.append("")
|
|
308
|
+
lines.append(_dim("Steps", enabled=color))
|
|
309
|
+
for step in visible_steps:
|
|
310
|
+
lines.extend(format_runtime_step_lines(step, color=color))
|
|
311
|
+
|
|
312
|
+
visible_observations = visible_runtime_observations(
|
|
313
|
+
payload.get("observations"),
|
|
314
|
+
successful_only=bool(answer),
|
|
315
|
+
)
|
|
316
|
+
if visible_observations:
|
|
317
|
+
lines.append("")
|
|
318
|
+
lines.append(_dim("Results", enabled=color))
|
|
319
|
+
for observation, repeat_count in visible_observations:
|
|
320
|
+
lines.extend(
|
|
321
|
+
format_runtime_result_lines(
|
|
322
|
+
observation, color=color, repeat_count=repeat_count
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
return "\n".join(lines)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def visible_runtime_steps(steps: Any) -> list[dict]:
|
|
330
|
+
if not isinstance(steps, list):
|
|
331
|
+
return []
|
|
332
|
+
visible = []
|
|
333
|
+
for step in steps:
|
|
334
|
+
if isinstance(step, dict):
|
|
335
|
+
visible.append(step)
|
|
336
|
+
return visible
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def format_runtime_step_lines(step: dict, *, color: bool = False) -> list[str]:
|
|
340
|
+
state = str(step.get("state", "")).strip()
|
|
341
|
+
title = str(step.get("title", "")).strip()
|
|
342
|
+
detail = str(step.get("detail", "")).strip()
|
|
343
|
+
headline = join_non_empty([_status_icon(state, color=color), title], " ")
|
|
344
|
+
lines = _wrapped_block_lines(headline or _status_icon(state, color=color), prefix=" ")
|
|
345
|
+
if detail:
|
|
346
|
+
lines.extend(_indented_lines(detail, prefix=" "))
|
|
347
|
+
return lines
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def format_runtime_progress_event(event: Any, *, color: bool = False) -> str:
|
|
351
|
+
if not isinstance(event, dict):
|
|
352
|
+
return ""
|
|
353
|
+
event_type = str(event.get("type", "")).strip()
|
|
354
|
+
if event_type == "planner_started":
|
|
355
|
+
return _dim("working...", enabled=color)
|
|
356
|
+
if event_type == "planner_completed":
|
|
357
|
+
return ""
|
|
358
|
+
if event_type == "tool_started":
|
|
359
|
+
tool = str(event.get("tool", "")).strip() or "tool"
|
|
360
|
+
if _is_internal_progress_tool(tool):
|
|
361
|
+
return ""
|
|
362
|
+
return _dim("working...", enabled=color)
|
|
363
|
+
if event_type == "tool_completed":
|
|
364
|
+
status = str(event.get("status", "")).strip()
|
|
365
|
+
tool = str(event.get("tool", "")).strip() or "tool"
|
|
366
|
+
if _is_internal_progress_tool(tool) and status in {"ok", "done"}:
|
|
367
|
+
return ""
|
|
368
|
+
icon = _status_icon(status, color=color)
|
|
369
|
+
return join_non_empty([f"{icon} Done", _progress_duration(event)], " · ")
|
|
370
|
+
if event_type == "approval_required":
|
|
371
|
+
return ""
|
|
372
|
+
if event_type == "planner_failed":
|
|
373
|
+
return ""
|
|
374
|
+
return ""
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def visible_runtime_observations(
|
|
378
|
+
observations: Any,
|
|
379
|
+
*,
|
|
380
|
+
successful_only: bool = False,
|
|
381
|
+
) -> list[tuple[dict, int]]:
|
|
382
|
+
if not isinstance(observations, list):
|
|
383
|
+
return []
|
|
384
|
+
visible = []
|
|
385
|
+
for observation, repeat_count in _collapse_runtime_observations(observations):
|
|
386
|
+
if _is_internal_note_observation(observation):
|
|
387
|
+
continue
|
|
388
|
+
if successful_only and not _is_successful_observation(observation):
|
|
389
|
+
continue
|
|
390
|
+
visible.append((observation, repeat_count))
|
|
391
|
+
return visible
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def format_runtime_observation(
|
|
395
|
+
observation: dict,
|
|
396
|
+
*,
|
|
397
|
+
color: bool = False,
|
|
398
|
+
repeat_count: int = 1,
|
|
399
|
+
) -> str:
|
|
400
|
+
return "\n".join(
|
|
401
|
+
format_runtime_observation_lines(
|
|
402
|
+
observation, color=color, repeat_count=repeat_count
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def format_runtime_observation_lines(
|
|
408
|
+
observation: dict,
|
|
409
|
+
*,
|
|
410
|
+
color: bool = False,
|
|
411
|
+
repeat_count: int = 1,
|
|
412
|
+
) -> list[str]:
|
|
413
|
+
status = str(observation.get("status", "")).strip() or "-"
|
|
414
|
+
tool = str(observation.get("tool", "")).strip() or "-"
|
|
415
|
+
duration = str(observation.get("duration_seconds", "")).strip()
|
|
416
|
+
summary = summarize_runtime_output(observation.get("output"), tool=tool)
|
|
417
|
+
suffix = f" x{repeat_count}" if repeat_count > 1 else ""
|
|
418
|
+
error_code = str(observation.get("error_code", "")).strip()
|
|
419
|
+
error = str(observation.get("error", "")).strip()
|
|
420
|
+
|
|
421
|
+
headline = [join_non_empty([_status_icon(status, color=color), tool + suffix], " ")]
|
|
422
|
+
if duration:
|
|
423
|
+
headline.append(_dim(f"{duration}s", enabled=color))
|
|
424
|
+
if summary:
|
|
425
|
+
headline.append(_dim(summary, enabled=color))
|
|
426
|
+
lines = [" " + " · ".join(headline)]
|
|
427
|
+
if error_code or error:
|
|
428
|
+
lines.extend(_indented_lines(join_non_empty([error_code, error], " "), prefix=" "))
|
|
429
|
+
return lines
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def format_runtime_result_lines(
|
|
433
|
+
observation: dict,
|
|
434
|
+
*,
|
|
435
|
+
color: bool = False,
|
|
436
|
+
repeat_count: int = 1,
|
|
437
|
+
) -> list[str]:
|
|
438
|
+
status = str(observation.get("status", "")).strip()
|
|
439
|
+
tool = str(observation.get("tool", "")).strip()
|
|
440
|
+
summary = summarize_runtime_user_result(observation.get("output"), tool=tool)
|
|
441
|
+
if not summary:
|
|
442
|
+
summary = _user_action_label(tool)
|
|
443
|
+
suffix = f" x{repeat_count}" if repeat_count > 1 else ""
|
|
444
|
+
headline = join_non_empty([_status_icon(status, color=color), summary + suffix], " ")
|
|
445
|
+
error = _user_runtime_error(observation)
|
|
446
|
+
lines = _wrapped_block_lines(headline, prefix=" ")
|
|
447
|
+
if error:
|
|
448
|
+
lines.extend(_indented_lines(error, prefix=" "))
|
|
449
|
+
return lines
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def summarize_runtime_user_result(output: Any, *, tool: str = "") -> str:
|
|
453
|
+
if not isinstance(output, dict) or not output:
|
|
454
|
+
return ""
|
|
455
|
+
normalized_tool = tool.strip()
|
|
456
|
+
if normalized_tool == "open_url":
|
|
457
|
+
url = _short_runtime_value(output.get("url", ""))
|
|
458
|
+
application = _short_runtime_value(output.get("application", ""))
|
|
459
|
+
return join_non_empty(
|
|
460
|
+
[
|
|
461
|
+
f"Opened {url}" if url else "Opened URL",
|
|
462
|
+
f"in {application}" if application else "",
|
|
463
|
+
],
|
|
464
|
+
" ",
|
|
465
|
+
)
|
|
466
|
+
if normalized_tool == "open_app":
|
|
467
|
+
application = _short_runtime_value(output.get("application", ""))
|
|
468
|
+
if output.get("opened") is True:
|
|
469
|
+
return f"Opened {application}" if application else "Opened app"
|
|
470
|
+
return application
|
|
471
|
+
if normalized_tool == "apply_patch":
|
|
472
|
+
changed = _summarize_changed_files(output.get("changed_files"))
|
|
473
|
+
return f"Updated files {changed}" if changed else "Updated files"
|
|
474
|
+
if normalized_tool == "revert_patch":
|
|
475
|
+
changed = _summarize_changed_files(output.get("changed_files"))
|
|
476
|
+
return f"Restored files {changed}" if changed else "Restored files"
|
|
477
|
+
if normalized_tool == "read_file":
|
|
478
|
+
path = _short_runtime_value(output.get("path", ""))
|
|
479
|
+
return f"Read {path}" if path else "Read file"
|
|
480
|
+
if normalized_tool == "list_files":
|
|
481
|
+
root = _short_runtime_value(output.get("root", ""))
|
|
482
|
+
count = output.get("file_count")
|
|
483
|
+
count_label = f"{count} files" if count is not None else ""
|
|
484
|
+
return join_non_empty(
|
|
485
|
+
[f"Listed {root}" if root else "Listed files", count_label],
|
|
486
|
+
" · ",
|
|
487
|
+
)
|
|
488
|
+
if normalized_tool == "artifact":
|
|
489
|
+
title = _short_runtime_value(output.get("title", ""))
|
|
490
|
+
return f"Created artifact {title}" if title else "Created artifact"
|
|
491
|
+
if normalized_tool == "http_request":
|
|
492
|
+
url = _short_runtime_value(output.get("url", ""))
|
|
493
|
+
status_code = str(output.get("status_code", "")).strip()
|
|
494
|
+
return join_non_empty(
|
|
495
|
+
[f"Fetched {url}" if url else "Fetched URL", status_code],
|
|
496
|
+
" · ",
|
|
497
|
+
)
|
|
498
|
+
return summarize_runtime_output(output, tool=tool)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def summarize_runtime_output(output: Any, *, tool: str = "") -> str:
|
|
502
|
+
if not isinstance(output, dict) or not output:
|
|
503
|
+
return ""
|
|
504
|
+
tool_summary = _summarize_runtime_output_for_tool(tool, output)
|
|
505
|
+
if tool_summary:
|
|
506
|
+
return tool_summary
|
|
507
|
+
preferred_keys = [
|
|
508
|
+
"url",
|
|
509
|
+
"path",
|
|
510
|
+
"changed_files",
|
|
511
|
+
"file_count",
|
|
512
|
+
"application",
|
|
513
|
+
"opened",
|
|
514
|
+
"status_code",
|
|
515
|
+
"content_type",
|
|
516
|
+
"artifact_id",
|
|
517
|
+
"title",
|
|
518
|
+
]
|
|
519
|
+
items = []
|
|
520
|
+
for key in preferred_keys:
|
|
521
|
+
if key in output:
|
|
522
|
+
items.append(f"{key}={_short_runtime_value(output[key])}")
|
|
523
|
+
if not items:
|
|
524
|
+
for key in sorted(output)[:3]:
|
|
525
|
+
items.append(f"{key}={_short_runtime_value(output[key])}")
|
|
526
|
+
return ", ".join(items)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def approval_prompt(action_id: str, tool: str, *, color: bool = False) -> str:
|
|
530
|
+
return _color("Approve this action?", "yellow", enabled=color) + " [y/N/d] "
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def join_non_empty(values: list[str], separator: str) -> str:
|
|
534
|
+
return separator.join(value for value in values if value)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _format_run_status(payload: dict, status: str, *, color: bool) -> str:
|
|
538
|
+
parts = [_status_label(status, color=color)]
|
|
539
|
+
if status.strip() == "requires_approval":
|
|
540
|
+
parts.append("pending")
|
|
541
|
+
duration = str(payload.get("duration_seconds", "")).strip()
|
|
542
|
+
if duration:
|
|
543
|
+
parts.append(f"{duration}s")
|
|
544
|
+
return " · ".join(parts)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _format_runtime_approval_summary(pending: dict, *, color: bool) -> str:
|
|
548
|
+
lines = [_color("Approval needed", "yellow", enabled=color)]
|
|
549
|
+
lines.extend(_indented_lines(_format_pending_approval(pending), prefix=" "))
|
|
550
|
+
return "\n".join(lines)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _format_pending_approval(pending: dict) -> str:
|
|
554
|
+
tool = str(pending.get("tool", "")).strip()
|
|
555
|
+
lines = []
|
|
556
|
+
if tool:
|
|
557
|
+
lines.append(f"action {_approval_action_label(tool)}")
|
|
558
|
+
action_input = pending.get("input")
|
|
559
|
+
input_summary = _summarize_pending_input(action_input, tool=tool)
|
|
560
|
+
if input_summary:
|
|
561
|
+
lines.append(f"target {input_summary}")
|
|
562
|
+
reason = str(pending.get("reason", "")).strip()
|
|
563
|
+
if reason:
|
|
564
|
+
lines.append(f"reason {reason}")
|
|
565
|
+
return "\n".join(lines)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _approval_action_label(tool: str) -> str:
|
|
569
|
+
labels = {
|
|
570
|
+
"apply_patch": "Edit files",
|
|
571
|
+
"http_request": "Fetch URL",
|
|
572
|
+
"open_app": "Open app",
|
|
573
|
+
"open_url": "Open URL",
|
|
574
|
+
"revert_patch": "Restore files",
|
|
575
|
+
"shell_command": "Run command",
|
|
576
|
+
}
|
|
577
|
+
return labels.get(tool.strip(), tool.strip() or "Run action")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _user_action_label(tool: str) -> str:
|
|
581
|
+
labels = {
|
|
582
|
+
"apply_patch": "Updated files",
|
|
583
|
+
"http_request": "Fetched URL",
|
|
584
|
+
"open_app": "Opened app",
|
|
585
|
+
"open_url": "Opened URL",
|
|
586
|
+
"revert_patch": "Restored files",
|
|
587
|
+
"shell_command": "Ran command",
|
|
588
|
+
}
|
|
589
|
+
return labels.get(tool.strip(), "Completed action")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _user_runtime_error(observation: dict) -> str:
|
|
593
|
+
status = str(observation.get("status", "")).strip()
|
|
594
|
+
if status in {"ok", "done"}:
|
|
595
|
+
return ""
|
|
596
|
+
error = str(observation.get("error", "")).strip()
|
|
597
|
+
if error:
|
|
598
|
+
return error
|
|
599
|
+
return "Action did not complete."
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _summarize_pending_input(action_input: Any, *, tool: str) -> str:
|
|
603
|
+
if not isinstance(action_input, dict):
|
|
604
|
+
return ""
|
|
605
|
+
if tool in {"open_url", "http_request"}:
|
|
606
|
+
return _short_runtime_value(action_input.get("url", ""))
|
|
607
|
+
if tool == "open_app":
|
|
608
|
+
return _short_runtime_value(action_input.get("application", ""))
|
|
609
|
+
if tool == "shell_command":
|
|
610
|
+
return _short_runtime_value(action_input.get("command", ""))
|
|
611
|
+
return summarize_runtime_output(action_input, tool=tool)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _answer_lines(text: str) -> list[str]:
|
|
615
|
+
return _wrapped_block_lines(text, prefix=" ")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _indented_lines(text: str, prefix: str = " ") -> list[str]:
|
|
619
|
+
return _wrapped_block_lines(text, prefix=prefix)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _wrapped_block_lines(text: str, *, prefix: str) -> list[str]:
|
|
623
|
+
width = _ui_width()
|
|
624
|
+
wrap_width = max(20, width - len(prefix))
|
|
625
|
+
lines: list[str] = []
|
|
626
|
+
in_fence = False
|
|
627
|
+
for raw_line in text.splitlines():
|
|
628
|
+
line = raw_line.rstrip()
|
|
629
|
+
if line.strip().startswith("```"):
|
|
630
|
+
in_fence = not in_fence
|
|
631
|
+
lines.append(prefix + line)
|
|
632
|
+
continue
|
|
633
|
+
if in_fence or not line.strip():
|
|
634
|
+
lines.append(prefix + line)
|
|
635
|
+
continue
|
|
636
|
+
leading = line[: len(line) - len(line.lstrip())]
|
|
637
|
+
content = line.lstrip()
|
|
638
|
+
bullet_prefix = _markdown_continuation_prefix(content)
|
|
639
|
+
wrapped = _wrap_display_text(content, width=max(20, wrap_width - len(leading)))
|
|
640
|
+
if not wrapped:
|
|
641
|
+
lines.append(prefix + line)
|
|
642
|
+
continue
|
|
643
|
+
lines.append(prefix + leading + wrapped[0])
|
|
644
|
+
for continuation in wrapped[1:]:
|
|
645
|
+
lines.append(prefix + leading + bullet_prefix + continuation)
|
|
646
|
+
return lines
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _markdown_continuation_prefix(text: str) -> str:
|
|
650
|
+
stripped = text.lstrip()
|
|
651
|
+
if stripped.startswith(("- ", "* ")):
|
|
652
|
+
return " "
|
|
653
|
+
if len(stripped) > 3 and stripped[0].isdigit() and stripped[1:3] == ". ":
|
|
654
|
+
return " "
|
|
655
|
+
return ""
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _wrap_display_text(text: str, *, width: int) -> list[str]:
|
|
659
|
+
if _contains_wide_text(text):
|
|
660
|
+
return _hard_wrap_display_text(text, width=width)
|
|
661
|
+
wrapped = textwrap.wrap(
|
|
662
|
+
text,
|
|
663
|
+
width=width,
|
|
664
|
+
break_long_words=False,
|
|
665
|
+
break_on_hyphens=False,
|
|
666
|
+
)
|
|
667
|
+
if not wrapped:
|
|
668
|
+
return []
|
|
669
|
+
lines: list[str] = []
|
|
670
|
+
for line in wrapped:
|
|
671
|
+
lines.append(line)
|
|
672
|
+
return lines
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _hard_wrap_display_text(text: str, *, width: int) -> list[str]:
|
|
676
|
+
lines: list[str] = []
|
|
677
|
+
current = ""
|
|
678
|
+
current_width = 0
|
|
679
|
+
for char in text:
|
|
680
|
+
char_width = _display_width(char)
|
|
681
|
+
if current and current_width + char_width > width:
|
|
682
|
+
if _is_leading_punctuation(char):
|
|
683
|
+
previous = current[-1]
|
|
684
|
+
current = current[:-1].rstrip()
|
|
685
|
+
if current:
|
|
686
|
+
lines.append(current)
|
|
687
|
+
current = previous + char
|
|
688
|
+
current_width = _display_width(current)
|
|
689
|
+
continue
|
|
690
|
+
lines.append(current.rstrip())
|
|
691
|
+
current = ""
|
|
692
|
+
current_width = 0
|
|
693
|
+
current += char
|
|
694
|
+
current_width += char_width
|
|
695
|
+
if current:
|
|
696
|
+
lines.append(current.rstrip())
|
|
697
|
+
return lines
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _display_width(text: str) -> int:
|
|
701
|
+
width = 0
|
|
702
|
+
for char in text:
|
|
703
|
+
if unicodedata.combining(char):
|
|
704
|
+
continue
|
|
705
|
+
width += 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
|
|
706
|
+
return width
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _contains_wide_text(text: str) -> bool:
|
|
710
|
+
return any(unicodedata.east_asian_width(char) in {"F", "W"} for char in text)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _is_leading_punctuation(char: str) -> bool:
|
|
714
|
+
return char in "、。,!?;:,.!?;:)]})】》"
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _progress_duration(event: dict) -> str:
|
|
718
|
+
duration = str(event.get("duration_seconds", "")).strip()
|
|
719
|
+
return f"{duration}s" if duration else ""
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _collapse_runtime_observations(observations: list) -> list[tuple[dict, int]]:
|
|
723
|
+
collapsed: list[tuple[dict, int]] = []
|
|
724
|
+
last_signature = None
|
|
725
|
+
for observation in observations:
|
|
726
|
+
if not isinstance(observation, dict):
|
|
727
|
+
continue
|
|
728
|
+
signature = _runtime_observation_signature(observation)
|
|
729
|
+
if collapsed and signature == last_signature:
|
|
730
|
+
previous, count = collapsed[-1]
|
|
731
|
+
collapsed[-1] = (previous, count + 1)
|
|
732
|
+
continue
|
|
733
|
+
collapsed.append((observation, 1))
|
|
734
|
+
last_signature = signature
|
|
735
|
+
return collapsed
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _runtime_observation_signature(observation: dict) -> str:
|
|
739
|
+
stable_observation = {
|
|
740
|
+
"action_id": observation.get("action_id"),
|
|
741
|
+
"error": observation.get("error"),
|
|
742
|
+
"error_code": observation.get("error_code"),
|
|
743
|
+
"output": observation.get("output"),
|
|
744
|
+
"status": observation.get("status"),
|
|
745
|
+
"tool": observation.get("tool"),
|
|
746
|
+
}
|
|
747
|
+
return json.dumps(json_ready(stable_observation), ensure_ascii=False, sort_keys=True)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _is_internal_note_observation(observation: dict) -> bool:
|
|
751
|
+
return (
|
|
752
|
+
str(observation.get("tool", "")).strip() == "note"
|
|
753
|
+
and str(observation.get("status", "")).strip() in {"ok", "done"}
|
|
754
|
+
and not str(observation.get("error_code", "")).strip()
|
|
755
|
+
and not str(observation.get("error", "")).strip()
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _is_successful_observation(observation: dict) -> bool:
|
|
760
|
+
return str(observation.get("status", "")).strip() in {"ok", "done"}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def _is_internal_progress_tool(tool: str) -> bool:
|
|
764
|
+
return tool.strip() == "note"
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _summarize_runtime_output_for_tool(tool: str, output: dict) -> str:
|
|
768
|
+
normalized_tool = tool.strip()
|
|
769
|
+
if normalized_tool in {"apply_patch", "revert_patch"}:
|
|
770
|
+
return _summarize_changed_files(output.get("changed_files"))
|
|
771
|
+
if normalized_tool == "open_url":
|
|
772
|
+
return join_non_empty(
|
|
773
|
+
[
|
|
774
|
+
_short_runtime_value(output.get("url", "")),
|
|
775
|
+
_short_runtime_value(output.get("application", "")),
|
|
776
|
+
"opened" if output.get("opened") is True else "",
|
|
777
|
+
],
|
|
778
|
+
" · ",
|
|
779
|
+
)
|
|
780
|
+
if normalized_tool == "open_app":
|
|
781
|
+
return join_non_empty(
|
|
782
|
+
[
|
|
783
|
+
_short_runtime_value(output.get("application", "")),
|
|
784
|
+
"opened" if output.get("opened") is True else "",
|
|
785
|
+
],
|
|
786
|
+
" · ",
|
|
787
|
+
)
|
|
788
|
+
if normalized_tool == "http_request":
|
|
789
|
+
return join_non_empty(
|
|
790
|
+
[
|
|
791
|
+
_short_runtime_value(output.get("url", "")),
|
|
792
|
+
str(output.get("status_code", "")).strip(),
|
|
793
|
+
_short_runtime_value(output.get("content_type", "")),
|
|
794
|
+
],
|
|
795
|
+
" · ",
|
|
796
|
+
)
|
|
797
|
+
if normalized_tool == "read_file":
|
|
798
|
+
return join_non_empty(
|
|
799
|
+
[
|
|
800
|
+
_short_runtime_value(output.get("path", "")),
|
|
801
|
+
_bytes_label(output.get("bytes")),
|
|
802
|
+
"truncated" if output.get("truncated") is True else "",
|
|
803
|
+
],
|
|
804
|
+
" · ",
|
|
805
|
+
)
|
|
806
|
+
if normalized_tool == "list_files":
|
|
807
|
+
return join_non_empty(
|
|
808
|
+
[
|
|
809
|
+
_short_runtime_value(output.get("root", "")),
|
|
810
|
+
f"{output.get('file_count')} files"
|
|
811
|
+
if output.get("file_count") is not None
|
|
812
|
+
else "",
|
|
813
|
+
"truncated" if output.get("truncated") is True else "",
|
|
814
|
+
],
|
|
815
|
+
" · ",
|
|
816
|
+
)
|
|
817
|
+
if normalized_tool == "artifact":
|
|
818
|
+
return join_non_empty(
|
|
819
|
+
[
|
|
820
|
+
_short_runtime_value(output.get("title", "")),
|
|
821
|
+
_short_runtime_value(output.get("kind", "")),
|
|
822
|
+
_short_runtime_value(output.get("format", "")),
|
|
823
|
+
_bytes_label(output.get("bytes")),
|
|
824
|
+
],
|
|
825
|
+
" · ",
|
|
826
|
+
)
|
|
827
|
+
return ""
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _summarize_changed_files(changed_files: Any) -> str:
|
|
831
|
+
if not isinstance(changed_files, list) or not changed_files:
|
|
832
|
+
return ""
|
|
833
|
+
parts = []
|
|
834
|
+
for item in changed_files[:3]:
|
|
835
|
+
if isinstance(item, dict):
|
|
836
|
+
operation = str(item.get("operation", "")).strip()
|
|
837
|
+
path = str(item.get("path", "")).strip()
|
|
838
|
+
bytes_label = _bytes_label(item.get("bytes"))
|
|
839
|
+
parts.append(join_non_empty([operation, path, bytes_label], " "))
|
|
840
|
+
else:
|
|
841
|
+
parts.append(_short_runtime_value(item))
|
|
842
|
+
if len(changed_files) > 3:
|
|
843
|
+
parts.append(f"+{len(changed_files) - 3} more")
|
|
844
|
+
return "; ".join(part for part in parts if part)
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _bytes_label(value: Any) -> str:
|
|
848
|
+
if value in {None, ""}:
|
|
849
|
+
return ""
|
|
850
|
+
try:
|
|
851
|
+
return f"{int(value)}B"
|
|
852
|
+
except (TypeError, ValueError):
|
|
853
|
+
return ""
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _status_label(status: str, *, color: bool = False) -> str:
|
|
857
|
+
normalized = status.strip() or "-"
|
|
858
|
+
color_name = {
|
|
859
|
+
"done": "green",
|
|
860
|
+
"ok": "green",
|
|
861
|
+
"failed": "red",
|
|
862
|
+
"requires_approval": "yellow",
|
|
863
|
+
"cancelled": "yellow",
|
|
864
|
+
}.get(normalized, "cyan")
|
|
865
|
+
labels = {
|
|
866
|
+
"done": "Done",
|
|
867
|
+
"ok": "Done",
|
|
868
|
+
"failed": "Failed",
|
|
869
|
+
"requires_approval": "Approval",
|
|
870
|
+
"cancelled": "Cancelled",
|
|
871
|
+
}
|
|
872
|
+
label = labels.get(normalized, normalized)
|
|
873
|
+
return _color(label, color_name, enabled=color)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _status_icon(status: str, *, color: bool = False) -> str:
|
|
877
|
+
normalized = status.strip()
|
|
878
|
+
if normalized in {"done", "ok"}:
|
|
879
|
+
return _color("✓", "green", enabled=color)
|
|
880
|
+
if normalized == "failed":
|
|
881
|
+
return _color("✗", "red", enabled=color)
|
|
882
|
+
if normalized in {"requires_approval", "cancelled"}:
|
|
883
|
+
return _color("!", "yellow", enabled=color)
|
|
884
|
+
return _color("•", "cyan", enabled=color)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _short_runtime_value(value: Any) -> str:
|
|
888
|
+
if isinstance(value, str):
|
|
889
|
+
text = str(value)
|
|
890
|
+
elif isinstance(value, bool):
|
|
891
|
+
text = json.dumps(value)
|
|
892
|
+
elif isinstance(value, (int, float)):
|
|
893
|
+
text = str(value)
|
|
894
|
+
else:
|
|
895
|
+
text = json.dumps(json_ready(value), ensure_ascii=False, sort_keys=True)
|
|
896
|
+
if len(text) > 96:
|
|
897
|
+
return text[:93] + "..."
|
|
898
|
+
return text
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _one_line_text(text: str) -> str:
|
|
902
|
+
compact = " ".join(text.split())
|
|
903
|
+
if len(compact) > 96:
|
|
904
|
+
return compact[:93] + "..."
|
|
905
|
+
return compact
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _ui_width() -> int:
|
|
909
|
+
return max(40, shutil.get_terminal_size((100, 24)).columns)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
def _dim(text: str, *, enabled: bool) -> str:
|
|
913
|
+
return _color(text, "dim", enabled=enabled)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _color(text: str, style: str, *, enabled: bool) -> str:
|
|
917
|
+
if not enabled:
|
|
918
|
+
return text
|
|
919
|
+
codes = {
|
|
920
|
+
"bold": "1",
|
|
921
|
+
"dim": "2",
|
|
922
|
+
"green": "32",
|
|
923
|
+
"red": "31",
|
|
924
|
+
"yellow": "33",
|
|
925
|
+
"cyan": "36",
|
|
926
|
+
"white": "97",
|
|
927
|
+
}
|
|
928
|
+
code = codes.get(style)
|
|
929
|
+
if not code:
|
|
930
|
+
return text
|
|
931
|
+
return f"\033[{code}m{text}\033[0m"
|