kimi-cli 0.44__py3-none-any.whl → 0.78__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.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
kimi_cli/utils/envvar.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
_TRUE_VALUES = {"1", "true", "t", "yes", "y"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_env_bool(name: str, default: bool = False) -> bool:
|
|
9
|
+
value = os.getenv(name)
|
|
10
|
+
if value is None:
|
|
11
|
+
return default
|
|
12
|
+
return value.strip().lower() in _TRUE_VALUES
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def read_frontmatter(path: Path) -> dict[str, Any] | None:
|
|
10
|
+
"""
|
|
11
|
+
Read the YAML frontmatter at the start of a file.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
path: Path to an existing file that may contain frontmatter.
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
ValueError: If the frontmatter YAML is invalid.
|
|
18
|
+
"""
|
|
19
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
20
|
+
first_line = handle.readline()
|
|
21
|
+
if not first_line or first_line.strip() != "---":
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
frontmatter_lines: list[str] = []
|
|
25
|
+
for line in handle:
|
|
26
|
+
if line.strip() == "---":
|
|
27
|
+
break
|
|
28
|
+
frontmatter_lines.append(line)
|
|
29
|
+
else:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
frontmatter = "".join(frontmatter_lines).strip()
|
|
33
|
+
if not frontmatter:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
raw_data: Any = yaml.safe_load(frontmatter)
|
|
38
|
+
except yaml.YAMLError as exc:
|
|
39
|
+
raise ValueError("Invalid frontmatter YAML.") from exc
|
|
40
|
+
|
|
41
|
+
if not isinstance(raw_data, dict):
|
|
42
|
+
raise ValueError("Frontmatter YAML must be a mapping.")
|
|
43
|
+
|
|
44
|
+
return cast(dict[str, Any], raw_data)
|
kimi_cli/utils/logging.py
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from
|
|
3
|
+
from typing import IO, AnyStr
|
|
4
4
|
|
|
5
|
-
logger
|
|
5
|
+
from loguru import logger
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class StreamToLogger(IO[str]):
|
|
9
9
|
def __init__(self, level: str = "ERROR"):
|
|
10
10
|
self._level = level
|
|
11
11
|
|
|
12
|
-
def write(self,
|
|
13
|
-
|
|
12
|
+
def write(self, s: AnyStr, /) -> int:
|
|
13
|
+
text = repr(s) if isinstance(s, bytes) else s
|
|
14
|
+
for line in text.rstrip().splitlines():
|
|
14
15
|
logger.opt(depth=1).log(self._level, line.rstrip())
|
|
15
|
-
return len(
|
|
16
|
+
return len(s)
|
|
16
17
|
|
|
17
18
|
def flush(self) -> None:
|
|
18
19
|
pass
|
kimi_cli/utils/message.py
CHANGED
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
from
|
|
1
|
+
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from kosong.message import Message
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
"""Extract text from a message."""
|
|
6
|
-
if isinstance(message.content, str):
|
|
7
|
-
return message.content
|
|
8
|
-
return "\n".join(part.text for part in message.content if isinstance(part, TextPart))
|
|
5
|
+
from kimi_cli.wire.types import TextPart
|
|
9
6
|
|
|
10
7
|
|
|
11
8
|
def message_stringify(message: Message) -> str:
|
|
12
9
|
"""Get a string representation of a message."""
|
|
10
|
+
# TODO: this should be merged into `kosong.message.Message.extract_text`
|
|
13
11
|
parts: list[str] = []
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
parts.append(part.text)
|
|
20
|
-
else:
|
|
21
|
-
parts.append(f"[{part.type}]")
|
|
12
|
+
for part in message.content:
|
|
13
|
+
if isinstance(part, TextPart):
|
|
14
|
+
parts.append(part.text)
|
|
15
|
+
else:
|
|
16
|
+
parts.append(f"[{part.type}]")
|
|
22
17
|
return "".join(parts)
|
kimi_cli/utils/path.py
CHANGED
|
@@ -1,23 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
1
5
|
import re
|
|
2
|
-
from pathlib import Path
|
|
6
|
+
from pathlib import Path, PurePath
|
|
7
|
+
from stat import S_ISDIR
|
|
3
8
|
|
|
4
9
|
import aiofiles.os
|
|
10
|
+
from kaos.path import KaosPath
|
|
11
|
+
|
|
12
|
+
_ROTATION_OPEN_FLAGS = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
13
|
+
_ROTATION_FILE_MODE = 0o600
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _reserve_rotation_path(path: Path) -> bool:
|
|
17
|
+
"""Atomically create an empty file as a reservation for *path*."""
|
|
18
|
+
|
|
19
|
+
def _create() -> None:
|
|
20
|
+
fd = os.open(str(path), _ROTATION_OPEN_FLAGS, _ROTATION_FILE_MODE)
|
|
21
|
+
os.close(fd)
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
await asyncio.to_thread(_create)
|
|
25
|
+
except FileExistsError:
|
|
26
|
+
return False
|
|
27
|
+
return True
|
|
5
28
|
|
|
6
29
|
|
|
7
30
|
async def next_available_rotation(path: Path) -> Path | None:
|
|
31
|
+
"""Return a reserved rotation path for *path* or ``None`` if parent is missing.
|
|
32
|
+
|
|
33
|
+
The caller must overwrite/reuse the returned path immediately because this helper
|
|
34
|
+
commits an empty placeholder file to guarantee uniqueness. It is therefore suited
|
|
35
|
+
for rotating *files* (like history logs) but **not** directory creation.
|
|
8
36
|
"""
|
|
9
|
-
|
|
10
|
-
"""
|
|
37
|
+
|
|
11
38
|
if not path.parent.exists():
|
|
12
39
|
return None
|
|
40
|
+
|
|
13
41
|
base_name = path.stem
|
|
14
42
|
suffix = path.suffix
|
|
15
43
|
pattern = re.compile(rf"^{re.escape(base_name)}_(\d+){re.escape(suffix)}$")
|
|
16
44
|
max_num = 0
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
45
|
+
for entry in await aiofiles.os.listdir(path.parent):
|
|
46
|
+
if match := pattern.match(entry):
|
|
47
|
+
max_num = max(max_num, int(match.group(1)))
|
|
48
|
+
|
|
21
49
|
next_num = max_num + 1
|
|
22
|
-
|
|
23
|
-
|
|
50
|
+
while True:
|
|
51
|
+
next_path = path.parent / f"{base_name}_{next_num}{suffix}"
|
|
52
|
+
if await _reserve_rotation_path(next_path):
|
|
53
|
+
return next_path
|
|
54
|
+
next_num += 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def list_directory(work_dir: KaosPath) -> str:
|
|
58
|
+
"""Return an ``ls``-like listing of *work_dir*.
|
|
59
|
+
|
|
60
|
+
This helper is used mainly to provide context to the LLM (for example
|
|
61
|
+
``KIMI_WORK_DIR_LS``) and to show top-level directory contents in tools.
|
|
62
|
+
It should therefore be robust against per-entry filesystem issues such as
|
|
63
|
+
broken symlinks or permission errors: a single bad entry must not crash
|
|
64
|
+
the whole CLI.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
entries: list[str] = []
|
|
68
|
+
# Iterate entries; tolerate per-entry stat failures (broken symlinks, permissions, etc.).
|
|
69
|
+
async for entry in work_dir.iterdir():
|
|
70
|
+
try:
|
|
71
|
+
st = await entry.stat()
|
|
72
|
+
except OSError:
|
|
73
|
+
# Broken symlink, permission error, etc. – keep listing other entries.
|
|
74
|
+
entries.append(f"?--------- {'?':>10} {entry.name} [stat failed]")
|
|
75
|
+
continue
|
|
76
|
+
mode = "d" if S_ISDIR(st.st_mode) else "-"
|
|
77
|
+
mode += "r" if st.st_mode & 0o400 else "-"
|
|
78
|
+
mode += "w" if st.st_mode & 0o200 else "-"
|
|
79
|
+
mode += "x" if st.st_mode & 0o100 else "-"
|
|
80
|
+
mode += "r" if st.st_mode & 0o040 else "-"
|
|
81
|
+
mode += "w" if st.st_mode & 0o020 else "-"
|
|
82
|
+
mode += "x" if st.st_mode & 0o010 else "-"
|
|
83
|
+
mode += "r" if st.st_mode & 0o004 else "-"
|
|
84
|
+
mode += "w" if st.st_mode & 0o002 else "-"
|
|
85
|
+
mode += "x" if st.st_mode & 0o001 else "-"
|
|
86
|
+
entries.append(f"{mode} {st.st_size:>10} {entry.name}")
|
|
87
|
+
return "\n".join(entries)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def shorten_home(path: KaosPath) -> KaosPath:
|
|
91
|
+
"""
|
|
92
|
+
Convert absolute path to use `~` for home directory.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
home = KaosPath.home()
|
|
96
|
+
p = path.relative_to(home)
|
|
97
|
+
return KaosPath("~") / p
|
|
98
|
+
except Exception:
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_within_directory(path: KaosPath, directory: KaosPath) -> bool:
|
|
103
|
+
"""
|
|
104
|
+
Check whether *path* is contained within *directory* using pure path semantics.
|
|
105
|
+
Both arguments should already be canonicalized (e.g. via KaosPath.canonical()).
|
|
106
|
+
"""
|
|
107
|
+
candidate = PurePath(str(path))
|
|
108
|
+
base = PurePath(str(directory))
|
|
109
|
+
try:
|
|
110
|
+
candidate.relative_to(base)
|
|
111
|
+
return True
|
|
112
|
+
except ValueError:
|
|
113
|
+
return False
|
kimi_cli/utils/pyinstaller.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from PyInstaller.utils.hooks import collect_data_files, collect_submodules
|
|
2
4
|
|
|
3
5
|
hiddenimports = collect_submodules("kimi_cli.tools")
|
|
@@ -9,9 +11,13 @@ datas = (
|
|
|
9
11
|
"agents/**/*.md",
|
|
10
12
|
"deps/bin/**",
|
|
11
13
|
"prompts/**/*.md",
|
|
14
|
+
"skills/**",
|
|
12
15
|
"tools/**/*.md",
|
|
13
16
|
"CHANGELOG.md",
|
|
14
17
|
],
|
|
18
|
+
excludes=[
|
|
19
|
+
"tools/*.md",
|
|
20
|
+
],
|
|
15
21
|
)
|
|
16
22
|
+ collect_data_files(
|
|
17
23
|
"dateparser",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Project-wide Rich configuration helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from rich import _wrap
|
|
9
|
+
|
|
10
|
+
# Regex used by Rich to compute break opportunities during wrapping.
|
|
11
|
+
_DEFAULT_WRAP_PATTERN: Final[re.Pattern[str]] = re.compile(r"\s*\S+\s*")
|
|
12
|
+
_CHAR_WRAP_PATTERN: Final[re.Pattern[str]] = re.compile(r".", re.DOTALL)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def enable_character_wrap() -> None:
|
|
16
|
+
"""Switch Rich's wrapping logic to break on every character.
|
|
17
|
+
|
|
18
|
+
Rich's default behavior tries to preserve whole words; we override the
|
|
19
|
+
internal regex so markdown rendering can fold text at any column once it
|
|
20
|
+
exceeds the terminal width.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
_wrap.re_word = _CHAR_WRAP_PATTERN
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def restore_word_wrap() -> None:
|
|
27
|
+
"""Restore Rich's default word-based wrapping."""
|
|
28
|
+
|
|
29
|
+
_wrap.re_word = _DEFAULT_WRAP_PATTERN
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Apply character-based wrapping globally for the CLI.
|
|
33
|
+
enable_character_wrap()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from rich.columns import Columns
|
|
4
|
+
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
|
5
|
+
from rich.measure import Measurement
|
|
6
|
+
from rich.segment import Segment
|
|
7
|
+
from rich.text import Text
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _ShrinkToWidth:
|
|
11
|
+
def __init__(self, renderable: RenderableType, max_width: int) -> None:
|
|
12
|
+
self._renderable = renderable
|
|
13
|
+
self._max_width = max(max_width, 1)
|
|
14
|
+
|
|
15
|
+
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
|
|
16
|
+
width = self._resolve_width(options)
|
|
17
|
+
return Measurement(0, width)
|
|
18
|
+
|
|
19
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
20
|
+
width = self._resolve_width(options)
|
|
21
|
+
child_options = options.update(width=width)
|
|
22
|
+
yield from console.render(self._renderable, child_options)
|
|
23
|
+
|
|
24
|
+
def _resolve_width(self, options: ConsoleOptions) -> int:
|
|
25
|
+
return max(1, min(self._max_width, options.max_width))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _strip_trailing_spaces(segments: list[Segment]) -> list[Segment]:
|
|
29
|
+
lines = list(Segment.split_lines(segments))
|
|
30
|
+
trimmed: list[Segment] = []
|
|
31
|
+
n_lines = len(lines)
|
|
32
|
+
for index, line in enumerate(lines):
|
|
33
|
+
line_segments = list(line)
|
|
34
|
+
while line_segments:
|
|
35
|
+
segment = line_segments[-1]
|
|
36
|
+
if segment.control is not None:
|
|
37
|
+
break
|
|
38
|
+
trimmed_text = segment.text.rstrip(" ")
|
|
39
|
+
if trimmed_text != segment.text:
|
|
40
|
+
if trimmed_text:
|
|
41
|
+
line_segments[-1] = Segment(trimmed_text, segment.style, segment.control)
|
|
42
|
+
break
|
|
43
|
+
line_segments.pop()
|
|
44
|
+
continue
|
|
45
|
+
break
|
|
46
|
+
trimmed.extend(line_segments)
|
|
47
|
+
if index != n_lines - 1:
|
|
48
|
+
trimmed.append(Segment.line())
|
|
49
|
+
if trimmed:
|
|
50
|
+
trimmed.append(Segment.line())
|
|
51
|
+
return trimmed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BulletColumns:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
renderable: RenderableType,
|
|
58
|
+
*,
|
|
59
|
+
bullet_style: str | None = None,
|
|
60
|
+
bullet: RenderableType | None = None,
|
|
61
|
+
padding: int = 1,
|
|
62
|
+
) -> None:
|
|
63
|
+
self._renderable = renderable
|
|
64
|
+
self._bullet = bullet
|
|
65
|
+
self._bullet_style = bullet_style
|
|
66
|
+
self._padding = padding
|
|
67
|
+
|
|
68
|
+
def _bullet_renderable(self) -> RenderableType:
|
|
69
|
+
if self._bullet is not None:
|
|
70
|
+
return self._bullet
|
|
71
|
+
return Text("•", style=self._bullet_style or "")
|
|
72
|
+
|
|
73
|
+
def _available_width(self, console: Console, options: ConsoleOptions, bullet_width: int) -> int:
|
|
74
|
+
max_width = options.max_width or console.width or (bullet_width + self._padding + 1)
|
|
75
|
+
available = max_width - bullet_width - self._padding
|
|
76
|
+
return max(available, 1)
|
|
77
|
+
|
|
78
|
+
def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement:
|
|
79
|
+
bullet = self._bullet_renderable()
|
|
80
|
+
bullet_measure = Measurement.get(console, options, bullet)
|
|
81
|
+
bullet_width = max(bullet_measure.maximum, 1)
|
|
82
|
+
available = self._available_width(console, options, bullet_width)
|
|
83
|
+
constrained = _ShrinkToWidth(self._renderable, available)
|
|
84
|
+
columns = Columns([bullet, constrained], expand=False, padding=(0, self._padding))
|
|
85
|
+
return Measurement.get(console, options, columns)
|
|
86
|
+
|
|
87
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
88
|
+
bullet = self._bullet_renderable()
|
|
89
|
+
bullet_measure = Measurement.get(console, options, bullet)
|
|
90
|
+
bullet_width = max(bullet_measure.maximum, 1)
|
|
91
|
+
available = self._available_width(console, options, bullet_width)
|
|
92
|
+
columns = Columns(
|
|
93
|
+
[bullet, _ShrinkToWidth(self._renderable, available)],
|
|
94
|
+
expand=False,
|
|
95
|
+
padding=(0, self._padding),
|
|
96
|
+
)
|
|
97
|
+
segments = list(console.render(columns, options))
|
|
98
|
+
trimmed = _strip_trailing_spaces(segments)
|
|
99
|
+
yield from trimmed
|