@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from typing import Any, Callable, TextIO
7
+
8
+ from ..errors import QingflowApiError
9
+ from ..public_surface import cli_public_tool_spec_from_namespace
10
+ from ..response_trim import resolve_cli_tool_name, trim_error_response, trim_public_response
11
+ from .context import CliContext, build_cli_context
12
+ from .formatters import emit_json_result, emit_text_result
13
+ from .commands import register_all_commands
14
+
15
+
16
+ Handler = Callable[[argparse.Namespace, CliContext], dict[str, Any]]
17
+
18
+
19
+ def build_parser() -> argparse.ArgumentParser:
20
+ parser = argparse.ArgumentParser(prog="qingflow", description="Qingflow CLI")
21
+ parser.add_argument("--profile", default="default", help="会话 profile,默认 default")
22
+ parser.add_argument("--json", action="store_true", help="输出 JSON")
23
+ subparsers = parser.add_subparsers(dest="command", required=True)
24
+ register_all_commands(subparsers)
25
+ return parser
26
+
27
+
28
+ def main(argv: list[str] | None = None) -> None:
29
+ raise SystemExit(run(argv))
30
+
31
+
32
+ def run(
33
+ argv: list[str] | None = None,
34
+ *,
35
+ context_factory: Callable[[], CliContext] = build_cli_context,
36
+ stdout: TextIO | None = None,
37
+ stderr: TextIO | None = None,
38
+ ) -> int:
39
+ out = stdout or sys.stdout
40
+ err = stderr or sys.stderr
41
+ parser = build_parser()
42
+ normalized_argv = _normalize_global_args(list(argv) if argv is not None else sys.argv[1:])
43
+ try:
44
+ args = parser.parse_args(normalized_argv)
45
+ except SystemExit as exc:
46
+ return int(exc.code or 0)
47
+ setattr(args, "_stdin", sys.stdin)
48
+ setattr(args, "_stdout_stream", out)
49
+ setattr(args, "_stderr_stream", err)
50
+ handler = getattr(args, "handler", None)
51
+ if handler is None:
52
+ parser.print_help(out)
53
+ return 2
54
+ context = context_factory()
55
+ try:
56
+ if not bool(args.json):
57
+ _emit_cli_effective_context_notice(args, context, stream=err)
58
+ result = handler(args, context)
59
+ except RuntimeError as exc:
60
+ payload = trim_error_response(_parse_error_payload(exc))
61
+ return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
62
+ except QingflowApiError as exc:
63
+ payload = trim_error_response(exc.to_dict())
64
+ return _emit_error(payload, json_mode=bool(args.json), stdout=out, stderr=err)
65
+ finally:
66
+ context.close()
67
+
68
+ exit_code = _result_exit_code(result)
69
+ trimmed_result = trim_public_response(resolve_cli_tool_name(args), result) if isinstance(result, dict) else result
70
+ stream = out if bool(args.json) or exit_code == 0 else err
71
+ if bool(args.json):
72
+ emit_json_result(trimmed_result, stream=stream)
73
+ else:
74
+ emit_text_result(trimmed_result, hint=getattr(args, "format_hint", ""), stream=stream)
75
+ return exit_code
76
+
77
+
78
+ def _normalize_global_args(argv: list[str]) -> list[str]:
79
+ global_args: list[str] = []
80
+ remaining: list[str] = []
81
+ index = 0
82
+ while index < len(argv):
83
+ token = argv[index]
84
+ if token == "--json":
85
+ global_args.append(token)
86
+ index += 1
87
+ continue
88
+ if token == "--profile":
89
+ global_args.append(token)
90
+ if index + 1 >= len(argv):
91
+ global_args.append("")
92
+ break
93
+ global_args.append(argv[index + 1])
94
+ index += 2
95
+ continue
96
+ if token.startswith("--profile="):
97
+ global_args.append(token)
98
+ index += 1
99
+ continue
100
+ remaining.append(token)
101
+ index += 1
102
+ return global_args + remaining
103
+
104
+
105
+ def _parse_error_payload(exc: RuntimeError) -> dict[str, Any]:
106
+ raw = str(exc)
107
+ try:
108
+ payload = json.loads(raw)
109
+ except json.JSONDecodeError:
110
+ return {"category": "runtime", "message": raw}
111
+ return payload if isinstance(payload, dict) else {"category": "runtime", "message": raw}
112
+
113
+
114
+ def _emit_error(payload: dict[str, Any], *, json_mode: bool, stdout: TextIO, stderr: TextIO) -> int:
115
+ exit_code = _error_exit_code(payload)
116
+ if json_mode:
117
+ emit_json_result(payload, stream=stdout)
118
+ return exit_code
119
+ lines = [
120
+ f"Category: {payload.get('category') or 'error'}",
121
+ f"Message: {payload.get('message') or 'Unknown error'}",
122
+ ]
123
+ if payload.get("backend_code") is not None:
124
+ lines.append(f"Backend Code: {payload.get('backend_code')}")
125
+ if payload.get("request_id"):
126
+ lines.append(f"Request ID: {payload.get('request_id')}")
127
+ details = payload.get("details")
128
+ if isinstance(details, dict):
129
+ for key, value in details.items():
130
+ if isinstance(value, (str, int, float, bool)) or value is None:
131
+ lines.append(f"{key}: {value}")
132
+ stderr.write("\n".join(lines) + "\n")
133
+ return exit_code
134
+
135
+
136
+ def _error_exit_code(payload: dict[str, Any]) -> int:
137
+ category = str(payload.get("category") or "").lower()
138
+ if category in {"auth", "workspace"}:
139
+ return 3
140
+ return 4
141
+
142
+
143
+ def _result_exit_code(result: dict[str, Any]) -> int:
144
+ if not isinstance(result, dict):
145
+ return 0
146
+ if result.get("ok") is False:
147
+ return 4
148
+ status = str(result.get("status") or "").lower()
149
+ if status in {"failed", "blocked"}:
150
+ return 4
151
+ return 0
152
+
153
+
154
+ def _emit_cli_effective_context_notice(args: argparse.Namespace, context: CliContext, *, stream: TextIO) -> None:
155
+ spec = cli_public_tool_spec_from_namespace(args)
156
+ if spec is None or not spec.cli_show_effective_context:
157
+ return
158
+ hide_context_line = bool(getattr(args, "hide_effective_context_line", False))
159
+ sessions = getattr(context, "sessions", None)
160
+ if sessions is None or not hasattr(sessions, "get_profile"):
161
+ return
162
+ profile_name = str(getattr(args, "profile", "default") or "default")
163
+ try:
164
+ session_profile = sessions.get_profile(profile_name)
165
+ except Exception:
166
+ session_profile = None
167
+ workspace_id = getattr(session_profile, "selected_ws_id", None) if session_profile is not None else None
168
+ workspace_name = getattr(session_profile, "selected_ws_name", None) if session_profile is not None else None
169
+ if workspace_id is None:
170
+ workspace_label = "(not selected)"
171
+ elif workspace_name:
172
+ workspace_label = f"{workspace_name} ({workspace_id})"
173
+ else:
174
+ workspace_label = str(workspace_id)
175
+ lines: list[str] = []
176
+ if not hide_context_line:
177
+ lines.append(f"Context: profile={profile_name} workspace={workspace_label}")
178
+ if spec.cli_context_write and profile_name == "default":
179
+ lines.append("Warning: using default profile for a workspace-sensitive write command")
180
+ if not lines:
181
+ return
182
+ stream.write("\n".join(lines) + "\n")
183
+
184
+
185
+ if __name__ == "__main__":
186
+ main()
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from Crypto.Cipher import PKCS1_v1_5
8
+ from Crypto.PublicKey import RSA
9
+
10
+ from ..backend_client import BackendClient
11
+ from ..config import get_default_base_url, get_timeout_seconds, normalize_base_url
12
+ from ..errors import QingflowApiError
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class QingflowNativeLoginResult:
17
+ token: str
18
+ user_info: dict[str, Any]
19
+ login_token: str | None = None
20
+ flow: str = "qingflow_password"
21
+
22
+
23
+ class QingflowNativeLoginHelper:
24
+ def __init__(self, *, backend: BackendClient | None = None) -> None:
25
+ self._owns_backend = backend is None
26
+ self._backend = backend or BackendClient(timeout=get_timeout_seconds())
27
+
28
+ def close(self) -> None:
29
+ if self._owns_backend:
30
+ self._backend.close()
31
+
32
+ def login_with_password(
33
+ self,
34
+ *,
35
+ base_url: str | None,
36
+ email: str,
37
+ password: str,
38
+ ) -> QingflowNativeLoginResult:
39
+ normalized_base_url = normalize_base_url(base_url) or get_default_base_url()
40
+ normalized_email = str(email or "").strip()
41
+ normalized_password = str(password or "")
42
+ if not normalized_base_url:
43
+ raise QingflowApiError.config_error("base_url is required or configure default_base_url")
44
+ if not normalized_email:
45
+ raise QingflowApiError.config_error("email is required for Qingflow account login")
46
+ if not normalized_password:
47
+ raise QingflowApiError.config_error("password is required for Qingflow account login")
48
+
49
+ pubkey_payload = self._backend.public_request("GET", normalized_base_url, "/user/pubkey", qf_version=None)
50
+ pubkey = self._extract_pubkey(pubkey_payload)
51
+ encrypted_password = _encrypt_password(normalized_password, pubkey)
52
+ login_payload = self._backend.public_request(
53
+ "POST",
54
+ normalized_base_url,
55
+ "/user/login",
56
+ json_body={"email": normalized_email, "password": encrypted_password},
57
+ qf_version=None,
58
+ )
59
+ if not isinstance(login_payload, dict):
60
+ raise QingflowApiError(category="auth", message="Qingflow login did not return a valid response")
61
+
62
+ token = str(login_payload.get("token") or "").strip()
63
+ login_token = str(login_payload.get("loginToken") or "").strip() or None
64
+ if not token:
65
+ if login_token:
66
+ raise QingflowApiError(
67
+ category="auth",
68
+ message=(
69
+ "Qingflow account login requires additional security verification. "
70
+ "CLI password login currently does not complete the loginToken verification step."
71
+ ),
72
+ details={"login_token_present": True},
73
+ )
74
+ raise QingflowApiError(
75
+ category="auth",
76
+ message="Qingflow login succeeded but did not return a token",
77
+ )
78
+
79
+ user_info = login_payload.get("userInfo")
80
+ if not isinstance(user_info, dict):
81
+ user_info = {}
82
+ return QingflowNativeLoginResult(
83
+ token=token,
84
+ login_token=login_token,
85
+ user_info=user_info,
86
+ )
87
+
88
+ def _extract_pubkey(self, payload: Any) -> str:
89
+ if not isinstance(payload, dict):
90
+ raise QingflowApiError(category="auth", message="Qingflow pubkey response is invalid")
91
+ pubkey = str(payload.get("pubkey") or "").strip()
92
+ if not pubkey:
93
+ raise QingflowApiError(category="auth", message="Qingflow pubkey response did not include pubkey")
94
+ return pubkey
95
+
96
+
97
+ def login_with_qingflow_password(
98
+ *,
99
+ base_url: str | None,
100
+ email: str,
101
+ password: str,
102
+ ) -> QingflowNativeLoginResult:
103
+ helper = QingflowNativeLoginHelper()
104
+ try:
105
+ return helper.login_with_password(base_url=base_url, email=email, password=password)
106
+ finally:
107
+ helper.close()
108
+
109
+
110
+ def _encrypt_password(password: str, pubkey: str) -> str:
111
+ public_key = RSA.import_key(
112
+ "-----BEGIN PUBLIC KEY-----\n" + pubkey.strip() + "\n-----END PUBLIC KEY-----\n"
113
+ )
114
+ cipher = PKCS1_v1_5.new(public_key)
115
+ encrypted = cipher.encrypt(password.encode("utf-8"))
116
+ return base64.b64encode(encrypted).decode("ascii")
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import select
5
+ import shutil
6
+ from dataclasses import dataclass
7
+ from typing import Generic, Sequence, TextIO, TypeVar
8
+
9
+ try:
10
+ import termios
11
+ import tty
12
+ except ImportError: # pragma: no cover - non-POSIX fallback
13
+ termios = None
14
+ tty = None
15
+
16
+
17
+ T = TypeVar("T")
18
+ RAW_TTY_NEWLINE = "\r\n"
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class SelectionOption(Generic[T]):
23
+ value: T
24
+ label: str
25
+ hint: str | None = None
26
+
27
+
28
+ def select_option(
29
+ *,
30
+ title: str,
31
+ options: Sequence[SelectionOption[T]],
32
+ input_stream: TextIO,
33
+ output_stream: TextIO,
34
+ page_size: int = 8,
35
+ ) -> T | None:
36
+ if not options:
37
+ return None
38
+ if len(options) == 1:
39
+ return options[0].value
40
+ if not _supports_raw_selection(input_stream=input_stream, output_stream=output_stream):
41
+ return _select_option_via_prompt(title=title, options=options, input_stream=input_stream, output_stream=output_stream)
42
+ return _select_option_via_raw_terminal(
43
+ title=title,
44
+ options=options,
45
+ input_stream=input_stream,
46
+ output_stream=output_stream,
47
+ page_size=max(3, page_size),
48
+ )
49
+
50
+
51
+ def _supports_raw_selection(*, input_stream: TextIO, output_stream: TextIO) -> bool:
52
+ if termios is None or tty is None:
53
+ return False
54
+ if not bool(getattr(input_stream, "isatty", lambda: False)()) or not bool(getattr(output_stream, "isatty", lambda: False)()):
55
+ return False
56
+ return hasattr(input_stream, "fileno")
57
+
58
+
59
+ def _select_option_via_prompt(
60
+ *,
61
+ title: str,
62
+ options: Sequence[SelectionOption[T]],
63
+ input_stream: TextIO,
64
+ output_stream: TextIO,
65
+ ) -> T | None:
66
+ output_stream.write(title + "\n")
67
+ for index, option in enumerate(options, start=1):
68
+ suffix = f" ({option.hint})" if option.hint else ""
69
+ output_stream.write(f"{index}. {option.label}{suffix}\n")
70
+ output_stream.write("请输入编号并回车,留空取消: ")
71
+ output_stream.flush()
72
+ line = input_stream.readline()
73
+ selected = str(line or "").strip()
74
+ if not selected:
75
+ return None
76
+ if not selected.isdigit():
77
+ return None
78
+ index = int(selected) - 1
79
+ if index < 0 or index >= len(options):
80
+ return None
81
+ return options[index].value
82
+
83
+
84
+ def _select_option_via_raw_terminal(
85
+ *,
86
+ title: str,
87
+ options: Sequence[SelectionOption[T]],
88
+ input_stream: TextIO,
89
+ output_stream: TextIO,
90
+ page_size: int,
91
+ ) -> T | None:
92
+ fd = input_stream.fileno()
93
+ original_mode = termios.tcgetattr(fd)
94
+ selected_index = 0
95
+ output_stream.write("\x1b[?1049h\x1b[?25l")
96
+ output_stream.flush()
97
+ try:
98
+ tty.setraw(fd)
99
+ while True:
100
+ _render_options(
101
+ title=title,
102
+ options=options,
103
+ selected_index=selected_index,
104
+ output_stream=output_stream,
105
+ page_size=page_size,
106
+ )
107
+ key = _read_key(input_stream)
108
+ if key in ("\r", "\n"):
109
+ return options[selected_index].value
110
+ if key in ("\x03", "\x1b", "q", "Q"):
111
+ return None
112
+ if key in ("\x1b[A", "k", "K"):
113
+ selected_index = (selected_index - 1) % len(options)
114
+ continue
115
+ if key in ("\x1b[B", "j", "J"):
116
+ selected_index = (selected_index + 1) % len(options)
117
+ finally:
118
+ termios.tcsetattr(fd, termios.TCSADRAIN, original_mode)
119
+ output_stream.write("\x1b[?25h\x1b[?1049l")
120
+ output_stream.flush()
121
+
122
+
123
+ def _render_options(
124
+ *,
125
+ title: str,
126
+ options: Sequence[SelectionOption[object]],
127
+ selected_index: int,
128
+ output_stream: TextIO,
129
+ page_size: int,
130
+ ) -> None:
131
+ terminal_width = shutil.get_terminal_size((100, 20)).columns
132
+ total = len(options)
133
+ page_start = max(0, min(selected_index - page_size // 2, max(total - page_size, 0)))
134
+ visible = options[page_start: page_start + page_size]
135
+ lines = [
136
+ title,
137
+ "↑/↓ 或 j/k 选择,Enter 确认,q / Esc 取消",
138
+ "",
139
+ ]
140
+ for offset, option in enumerate(visible, start=page_start):
141
+ marker = ">" if offset == selected_index else " "
142
+ suffix = f" [{option.hint}]" if option.hint else ""
143
+ lines.append(_truncate_line(f"{marker} {option.label}{suffix}", width=terminal_width))
144
+ if total > page_size:
145
+ lines.append("")
146
+ lines.append(f"{selected_index + 1}/{total}")
147
+ output_stream.write("\x1b[2J\x1b[H")
148
+ output_stream.write(RAW_TTY_NEWLINE.join(lines))
149
+ output_stream.flush()
150
+
151
+
152
+ def _truncate_line(text: str, *, width: int) -> str:
153
+ if width <= 0 or len(text) <= width:
154
+ return text
155
+ if width <= 1:
156
+ return text[:width]
157
+ return text[: width - 1] + "…"
158
+
159
+
160
+ def _read_key(input_stream: TextIO) -> str:
161
+ first = input_stream.read(1)
162
+ if first != "\x1b":
163
+ return first
164
+ chunks = [first]
165
+ fd = input_stream.fileno()
166
+ while select.select([fd], [], [], 0.02)[0]:
167
+ chunk = os.read(fd, 1).decode("utf-8", errors="ignore")
168
+ if not chunk:
169
+ break
170
+ chunks.append(chunk)
171
+ if len(chunks) >= 3:
172
+ break
173
+ return "".join(chunks)