kimi-cli 0.35__py3-none-any.whl → 0.36__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 +14 -0
- kimi_cli/__init__.py +15 -2
- kimi_cli/soul/__init__.py +1 -1
- kimi_cli/soul/approval.py +3 -2
- kimi_cli/soul/compaction.py +105 -0
- kimi_cli/soul/kimisoul.py +94 -38
- kimi_cli/soul/wire.py +29 -4
- kimi_cli/tools/bash/__init__.py +3 -1
- kimi_cli/tools/file/__init__.py +8 -0
- kimi_cli/tools/file/patch.py +13 -1
- kimi_cli/tools/file/replace.py +13 -1
- kimi_cli/tools/file/write.py +13 -1
- kimi_cli/ui/__init__.py +1 -0
- kimi_cli/ui/print/__init__.py +1 -0
- kimi_cli/ui/shell/__init__.py +48 -102
- kimi_cli/ui/shell/console.py +27 -1
- kimi_cli/ui/shell/debug.py +187 -0
- kimi_cli/ui/shell/keyboard.py +115 -0
- kimi_cli/ui/shell/liveview.py +225 -6
- kimi_cli/ui/shell/metacmd.py +25 -67
- kimi_cli/ui/shell/setup.py +3 -6
- kimi_cli/ui/shell/visualize.py +105 -0
- kimi_cli/utils/provider.py +9 -1
- kimi_cli-0.36.dist-info/METADATA +150 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.36.dist-info}/RECORD +30 -26
- kimi_cli-0.35.dist-info/METADATA +0 -24
- /kimi_cli/prompts/{metacmds/__init__.py → __init__.py} +0 -0
- /kimi_cli/prompts/{metacmds/compact.md → compact.md} +0 -0
- /kimi_cli/prompts/{metacmds/init.md → init.md} +0 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.36.dist-info}/WHEEL +0 -0
- {kimi_cli-0.35.dist-info → kimi_cli-0.36.dist-info}/entry_points.txt +0 -0
kimi_cli/ui/shell/__init__.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import signal
|
|
3
3
|
from collections.abc import Awaitable, Coroutine
|
|
4
|
+
from functools import partial
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
6
|
-
from kosong.base.message import ContentPart, TextPart, ToolCall, ToolCallPart
|
|
7
7
|
from kosong.chat_provider import APIStatusError, ChatProviderError
|
|
8
|
-
from kosong.tooling import ToolResult
|
|
9
8
|
from rich.console import Group, RenderableType
|
|
10
9
|
from rich.panel import Panel
|
|
11
10
|
from rich.table import Table
|
|
@@ -13,20 +12,12 @@ from rich.text import Text
|
|
|
13
12
|
|
|
14
13
|
from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul
|
|
15
14
|
from kimi_cli.soul.kimisoul import KimiSoul
|
|
16
|
-
from kimi_cli.soul.wire import (
|
|
17
|
-
ApprovalRequest,
|
|
18
|
-
ApprovalResponse,
|
|
19
|
-
StatusUpdate,
|
|
20
|
-
StepBegin,
|
|
21
|
-
StepInterrupted,
|
|
22
|
-
Wire,
|
|
23
|
-
)
|
|
24
15
|
from kimi_cli.ui import RunCancelled, run_soul
|
|
25
16
|
from kimi_cli.ui.shell.console import console
|
|
26
|
-
from kimi_cli.ui.shell.liveview import StepLiveView
|
|
27
17
|
from kimi_cli.ui.shell.metacmd import get_meta_command
|
|
28
18
|
from kimi_cli.ui.shell.prompt import CustomPromptSession, PromptMode, toast
|
|
29
19
|
from kimi_cli.ui.shell.update import UpdateResult, do_update
|
|
20
|
+
from kimi_cli.ui.shell.visualize import visualize
|
|
30
21
|
from kimi_cli.utils.logging import logger
|
|
31
22
|
|
|
32
23
|
|
|
@@ -46,7 +37,7 @@ class ShellApp:
|
|
|
46
37
|
if command is not None:
|
|
47
38
|
# run single command and exit
|
|
48
39
|
logger.info("Running agent with command: {command}", command=command)
|
|
49
|
-
return await self.
|
|
40
|
+
return await self._run_soul_command(command)
|
|
50
41
|
|
|
51
42
|
self._start_auto_update_task()
|
|
52
43
|
|
|
@@ -86,7 +77,7 @@ class ShellApp:
|
|
|
86
77
|
continue
|
|
87
78
|
|
|
88
79
|
logger.info("Running agent command: {command}", command=command)
|
|
89
|
-
await self.
|
|
80
|
+
await self._run_soul_command(command)
|
|
90
81
|
|
|
91
82
|
return True
|
|
92
83
|
|
|
@@ -115,7 +106,44 @@ class ShellApp:
|
|
|
115
106
|
finally:
|
|
116
107
|
loop.remove_signal_handler(signal.SIGINT)
|
|
117
108
|
|
|
118
|
-
async def
|
|
109
|
+
async def _run_meta_command(self, command_str: str):
|
|
110
|
+
parts = command_str.split(" ")
|
|
111
|
+
command_name = parts[0]
|
|
112
|
+
command_args = parts[1:]
|
|
113
|
+
command = get_meta_command(command_name)
|
|
114
|
+
if command is None:
|
|
115
|
+
console.print(f"Meta command /{command_name} not found")
|
|
116
|
+
return
|
|
117
|
+
if command.kimi_soul_only and not isinstance(self.soul, KimiSoul):
|
|
118
|
+
console.print(f"Meta command /{command_name} not supported")
|
|
119
|
+
return
|
|
120
|
+
logger.debug(
|
|
121
|
+
"Running meta command: {command_name} with args: {command_args}",
|
|
122
|
+
command_name=command_name,
|
|
123
|
+
command_args=command_args,
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
ret = command.func(self, command_args)
|
|
127
|
+
if isinstance(ret, Awaitable):
|
|
128
|
+
await ret
|
|
129
|
+
except LLMNotSet:
|
|
130
|
+
logger.error("LLM not set")
|
|
131
|
+
console.print("[red]LLM not set, send /setup to configure[/red]")
|
|
132
|
+
except ChatProviderError as e:
|
|
133
|
+
logger.exception("LLM provider error:")
|
|
134
|
+
console.print(f"[red]LLM provider error: {e}[/red]")
|
|
135
|
+
except asyncio.CancelledError:
|
|
136
|
+
logger.info("Interrupted by user")
|
|
137
|
+
console.print("[red]Interrupted by user[/red]")
|
|
138
|
+
except Reload:
|
|
139
|
+
# just propagate
|
|
140
|
+
raise
|
|
141
|
+
except BaseException as e:
|
|
142
|
+
logger.exception("Unknown error:")
|
|
143
|
+
console.print(f"[red]Unknown error: {e}[/red]")
|
|
144
|
+
raise # re-raise unknown error
|
|
145
|
+
|
|
146
|
+
async def _run_soul_command(self, command: str) -> bool:
|
|
119
147
|
"""
|
|
120
148
|
Run the soul and handle any known exceptions.
|
|
121
149
|
|
|
@@ -132,7 +160,12 @@ class ShellApp:
|
|
|
132
160
|
loop.add_signal_handler(signal.SIGINT, _handler)
|
|
133
161
|
|
|
134
162
|
try:
|
|
135
|
-
await run_soul(
|
|
163
|
+
await run_soul(
|
|
164
|
+
self.soul,
|
|
165
|
+
command,
|
|
166
|
+
partial(visualize, initial_status=self.soul.status),
|
|
167
|
+
cancel_event,
|
|
168
|
+
)
|
|
136
169
|
return True
|
|
137
170
|
except LLMNotSet:
|
|
138
171
|
logger.error("LLM not set")
|
|
@@ -193,93 +226,6 @@ class ShellApp:
|
|
|
193
226
|
task.add_done_callback(_cleanup)
|
|
194
227
|
return task
|
|
195
228
|
|
|
196
|
-
async def _visualize(self, wire: Wire):
|
|
197
|
-
"""
|
|
198
|
-
A loop to consume agent events and visualize the agent behavior.
|
|
199
|
-
This loop never raise any exception except asyncio.CancelledError.
|
|
200
|
-
"""
|
|
201
|
-
try:
|
|
202
|
-
# expect a StepBegin
|
|
203
|
-
assert isinstance(await wire.receive(), StepBegin)
|
|
204
|
-
|
|
205
|
-
while True:
|
|
206
|
-
# spin the moon at the beginning of each step
|
|
207
|
-
with console.status("", spinner="moon"):
|
|
208
|
-
msg = await wire.receive()
|
|
209
|
-
|
|
210
|
-
with StepLiveView(self.soul.status) as step:
|
|
211
|
-
# visualization loop for one step
|
|
212
|
-
while True:
|
|
213
|
-
match msg:
|
|
214
|
-
case TextPart(text=text):
|
|
215
|
-
step.append_text(text)
|
|
216
|
-
case ContentPart():
|
|
217
|
-
# TODO: support more content parts
|
|
218
|
-
step.append_text(f"[{msg.__class__.__name__}]")
|
|
219
|
-
case ToolCall():
|
|
220
|
-
step.append_tool_call(msg)
|
|
221
|
-
case ToolCallPart():
|
|
222
|
-
step.append_tool_call_part(msg)
|
|
223
|
-
case ToolResult():
|
|
224
|
-
step.append_tool_result(msg)
|
|
225
|
-
case ApprovalRequest():
|
|
226
|
-
msg.resolve(ApprovalResponse.APPROVE)
|
|
227
|
-
# TODO(approval): handle approval request
|
|
228
|
-
case StatusUpdate(status=status):
|
|
229
|
-
step.update_status(status)
|
|
230
|
-
case _:
|
|
231
|
-
break # break the step loop
|
|
232
|
-
msg = await wire.receive()
|
|
233
|
-
|
|
234
|
-
# cleanup the step live view
|
|
235
|
-
if isinstance(msg, StepInterrupted):
|
|
236
|
-
step.interrupt()
|
|
237
|
-
else:
|
|
238
|
-
step.finish()
|
|
239
|
-
|
|
240
|
-
if isinstance(msg, StepInterrupted):
|
|
241
|
-
# for StepInterrupted, the visualization loop should end immediately
|
|
242
|
-
break
|
|
243
|
-
|
|
244
|
-
assert isinstance(msg, StepBegin), "expect a StepBegin"
|
|
245
|
-
# start a new step
|
|
246
|
-
except asyncio.QueueShutDown:
|
|
247
|
-
logger.debug("Visualization loop shutting down")
|
|
248
|
-
|
|
249
|
-
async def _run_meta_command(self, command_str: str):
|
|
250
|
-
parts = command_str.split(" ")
|
|
251
|
-
command_name = parts[0]
|
|
252
|
-
command_args = parts[1:]
|
|
253
|
-
command = get_meta_command(command_name)
|
|
254
|
-
if command is None:
|
|
255
|
-
console.print(f"Meta command /{command_name} not found")
|
|
256
|
-
return
|
|
257
|
-
if command.kimi_soul_only and not isinstance(self.soul, KimiSoul):
|
|
258
|
-
console.print(f"Meta command /{command_name} not supported")
|
|
259
|
-
return
|
|
260
|
-
logger.debug(
|
|
261
|
-
"Running meta command: {command_name} with args: {command_args}",
|
|
262
|
-
command_name=command_name,
|
|
263
|
-
command_args=command_args,
|
|
264
|
-
)
|
|
265
|
-
try:
|
|
266
|
-
ret = command.func(self, command_args)
|
|
267
|
-
if isinstance(ret, Awaitable):
|
|
268
|
-
await ret
|
|
269
|
-
except LLMNotSet:
|
|
270
|
-
logger.error("LLM not set")
|
|
271
|
-
console.print("[red]LLM not set, send /setup to configure[/red]")
|
|
272
|
-
except ChatProviderError as e:
|
|
273
|
-
logger.exception("LLM provider error:")
|
|
274
|
-
console.print(f"[red]LLM provider error: {e}[/red]")
|
|
275
|
-
except Reload:
|
|
276
|
-
# just propagate
|
|
277
|
-
raise
|
|
278
|
-
except BaseException as e:
|
|
279
|
-
logger.exception("Unknown error:")
|
|
280
|
-
console.print(f"[red]Unknown error: {e}[/red]")
|
|
281
|
-
raise # re-raise unknown error
|
|
282
|
-
|
|
283
229
|
|
|
284
230
|
_KIMI_BLUE = "dodger_blue1"
|
|
285
231
|
_LOGO = f"""\
|
kimi_cli/ui/shell/console.py
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
1
|
from rich.console import Console
|
|
2
|
+
from rich.theme import Theme
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
_NEUTRAL_MARKDOWN_THEME = Theme(
|
|
5
|
+
{
|
|
6
|
+
"markdown.paragraph": "none",
|
|
7
|
+
"markdown.block_quote": "none",
|
|
8
|
+
"markdown.hr": "none",
|
|
9
|
+
"markdown.item": "none",
|
|
10
|
+
"markdown.item.bullet": "none",
|
|
11
|
+
"markdown.item.number": "none",
|
|
12
|
+
"markdown.link": "none",
|
|
13
|
+
"markdown.link_url": "none",
|
|
14
|
+
"markdown.h1": "none",
|
|
15
|
+
"markdown.h1.border": "none",
|
|
16
|
+
"markdown.h2": "none",
|
|
17
|
+
"markdown.h3": "none",
|
|
18
|
+
"markdown.h4": "none",
|
|
19
|
+
"markdown.h5": "none",
|
|
20
|
+
"markdown.h6": "none",
|
|
21
|
+
"markdown.em": "none",
|
|
22
|
+
"markdown.strong": "none",
|
|
23
|
+
"markdown.s": "none",
|
|
24
|
+
"status.spinner": "none",
|
|
25
|
+
},
|
|
26
|
+
inherit=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
console = Console(highlight=False, theme=_NEUTRAL_MARKDOWN_THEME)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
from kosong.base.message import (
|
|
5
|
+
AudioURLPart,
|
|
6
|
+
ContentPart,
|
|
7
|
+
ImageURLPart,
|
|
8
|
+
Message,
|
|
9
|
+
TextPart,
|
|
10
|
+
ThinkPart,
|
|
11
|
+
ToolCall,
|
|
12
|
+
)
|
|
13
|
+
from rich.console import Group
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.rule import Rule
|
|
16
|
+
from rich.syntax import Syntax
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from kimi_cli.soul.kimisoul import KimiSoul
|
|
20
|
+
from kimi_cli.ui.shell.console import console
|
|
21
|
+
from kimi_cli.ui.shell.metacmd import meta_command
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from kimi_cli.ui.shell import ShellApp
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _format_content_part(part: ContentPart) -> Text | Panel | Group:
|
|
28
|
+
"""Format a single content part."""
|
|
29
|
+
match part:
|
|
30
|
+
case TextPart(text=text):
|
|
31
|
+
# Check if it looks like a system tag
|
|
32
|
+
if text.strip().startswith("<system>") and text.strip().endswith("</system>"):
|
|
33
|
+
return Panel(
|
|
34
|
+
text.strip()[8:-9].strip(),
|
|
35
|
+
title="[dim]system[/dim]",
|
|
36
|
+
border_style="dim yellow",
|
|
37
|
+
padding=(0, 1),
|
|
38
|
+
)
|
|
39
|
+
return Text(text, style="white")
|
|
40
|
+
|
|
41
|
+
case ThinkPart(think=think):
|
|
42
|
+
return Panel(
|
|
43
|
+
think,
|
|
44
|
+
title="[dim]thinking[/dim]",
|
|
45
|
+
border_style="dim cyan",
|
|
46
|
+
padding=(0, 1),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
case ImageURLPart(image_url=img):
|
|
50
|
+
url_display = img.url[:80] + "..." if len(img.url) > 80 else img.url
|
|
51
|
+
id_text = f" (id: {img.id})" if img.id else ""
|
|
52
|
+
return Text(f"[Image{id_text}] {url_display}", style="blue")
|
|
53
|
+
|
|
54
|
+
case AudioURLPart(audio_url=audio):
|
|
55
|
+
url_display = audio.url[:80] + "..." if len(audio.url) > 80 else audio.url
|
|
56
|
+
id_text = f" (id: {audio.id})" if audio.id else ""
|
|
57
|
+
return Text(f"[Audio{id_text}] {url_display}", style="blue")
|
|
58
|
+
|
|
59
|
+
case _:
|
|
60
|
+
return Text(f"[Unknown content type: {type(part).__name__}]", style="red")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _format_tool_call(tool_call: ToolCall) -> Panel:
|
|
64
|
+
"""Format a tool call."""
|
|
65
|
+
args = tool_call.function.arguments or "{}"
|
|
66
|
+
try:
|
|
67
|
+
args_formatted = json.dumps(json.loads(args), indent=2)
|
|
68
|
+
args_syntax = Syntax(args_formatted, "json", theme="monokai", padding=(0, 1))
|
|
69
|
+
except json.JSONDecodeError:
|
|
70
|
+
args_syntax = Text(args, style="red")
|
|
71
|
+
|
|
72
|
+
content = Group(
|
|
73
|
+
Text(f"Function: {tool_call.function.name}", style="bold cyan"),
|
|
74
|
+
Text(f"Call ID: {tool_call.id}", style="dim"),
|
|
75
|
+
Text("Arguments:", style="bold"),
|
|
76
|
+
args_syntax,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return Panel(
|
|
80
|
+
content,
|
|
81
|
+
title="[bold yellow]Tool Call[/bold yellow]",
|
|
82
|
+
border_style="yellow",
|
|
83
|
+
padding=(0, 1),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _format_message(msg: Message, index: int) -> Panel:
|
|
88
|
+
"""Format a single message."""
|
|
89
|
+
# Role styling
|
|
90
|
+
role_colors = {
|
|
91
|
+
"system": "magenta",
|
|
92
|
+
"developer": "magenta",
|
|
93
|
+
"user": "green",
|
|
94
|
+
"assistant": "blue",
|
|
95
|
+
"tool": "yellow",
|
|
96
|
+
}
|
|
97
|
+
role_color = role_colors.get(msg.role, "white")
|
|
98
|
+
role_text = f"[bold {role_color}]{msg.role.upper()}[/bold {role_color}]"
|
|
99
|
+
|
|
100
|
+
# Add name if present
|
|
101
|
+
if msg.name:
|
|
102
|
+
role_text += f" [dim]({msg.name})[/dim]"
|
|
103
|
+
|
|
104
|
+
# Add tool call ID for tool messages
|
|
105
|
+
if msg.tool_call_id:
|
|
106
|
+
role_text += f" [dim]→ {msg.tool_call_id}[/dim]"
|
|
107
|
+
|
|
108
|
+
# Format content
|
|
109
|
+
content_items: list = []
|
|
110
|
+
|
|
111
|
+
if isinstance(msg.content, str):
|
|
112
|
+
content_items.append(Text(msg.content, style="white"))
|
|
113
|
+
else:
|
|
114
|
+
for part in msg.content:
|
|
115
|
+
formatted = _format_content_part(part)
|
|
116
|
+
content_items.append(formatted)
|
|
117
|
+
|
|
118
|
+
# Add tool calls if present
|
|
119
|
+
if msg.tool_calls:
|
|
120
|
+
if content_items:
|
|
121
|
+
content_items.append(Text()) # Empty line
|
|
122
|
+
for tool_call in msg.tool_calls:
|
|
123
|
+
content_items.append(_format_tool_call(tool_call))
|
|
124
|
+
|
|
125
|
+
# Combine all content
|
|
126
|
+
if not content_items:
|
|
127
|
+
content_items.append(Text("[empty message]", style="dim italic"))
|
|
128
|
+
|
|
129
|
+
group = Group(*content_items)
|
|
130
|
+
|
|
131
|
+
# Create panel
|
|
132
|
+
title = f"#{index + 1} {role_text}"
|
|
133
|
+
if msg.partial:
|
|
134
|
+
title += " [dim italic](partial)[/dim italic]"
|
|
135
|
+
|
|
136
|
+
return Panel(
|
|
137
|
+
group,
|
|
138
|
+
title=title,
|
|
139
|
+
border_style=role_color,
|
|
140
|
+
padding=(0, 1),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@meta_command(kimi_soul_only=True)
|
|
145
|
+
def debug(app: "ShellApp", args: list[str]):
|
|
146
|
+
"""Debug the context"""
|
|
147
|
+
assert isinstance(app.soul, KimiSoul)
|
|
148
|
+
|
|
149
|
+
context = app.soul._context
|
|
150
|
+
history = context.history
|
|
151
|
+
|
|
152
|
+
if not history:
|
|
153
|
+
console.print(
|
|
154
|
+
Panel(
|
|
155
|
+
"Context is empty - no messages yet",
|
|
156
|
+
border_style="yellow",
|
|
157
|
+
padding=(1, 2),
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
# Build the debug output
|
|
163
|
+
output_items = [
|
|
164
|
+
Panel(
|
|
165
|
+
Group(
|
|
166
|
+
Text(f"Total messages: {len(history)}", style="bold"),
|
|
167
|
+
Text(f"Token count: {context.token_count:,}", style="bold"),
|
|
168
|
+
Text(f"Checkpoints: {context.n_checkpoints}", style="bold"),
|
|
169
|
+
Text(f"Trajectory: {context._file_backend}", style="dim"),
|
|
170
|
+
),
|
|
171
|
+
title="[bold]Context Info[/bold]",
|
|
172
|
+
border_style="cyan",
|
|
173
|
+
padding=(0, 1),
|
|
174
|
+
),
|
|
175
|
+
Rule(style="dim"),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
# Add all messages
|
|
179
|
+
for idx, msg in enumerate(history):
|
|
180
|
+
output_items.append(_format_message(msg, idx))
|
|
181
|
+
|
|
182
|
+
# Display using rich pager
|
|
183
|
+
display_group = Group(*output_items)
|
|
184
|
+
|
|
185
|
+
# Use pager to display
|
|
186
|
+
with console.pager(styles=True):
|
|
187
|
+
console.print(display_group)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import sys
|
|
3
|
+
import termios
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import AsyncGenerator, Callable
|
|
7
|
+
from enum import Enum, auto
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class KeyEvent(Enum):
|
|
11
|
+
UP = auto()
|
|
12
|
+
DOWN = auto()
|
|
13
|
+
LEFT = auto()
|
|
14
|
+
RIGHT = auto()
|
|
15
|
+
ENTER = auto()
|
|
16
|
+
ESCAPE = auto()
|
|
17
|
+
TAB = auto()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def listen_for_keyboard() -> AsyncGenerator[KeyEvent]:
|
|
21
|
+
loop = asyncio.get_running_loop()
|
|
22
|
+
queue = asyncio.Queue[KeyEvent]()
|
|
23
|
+
cancel_event = threading.Event()
|
|
24
|
+
|
|
25
|
+
def emit(event: KeyEvent) -> None:
|
|
26
|
+
# print(f"emit: {event}")
|
|
27
|
+
loop.call_soon_threadsafe(queue.put_nowait, event)
|
|
28
|
+
|
|
29
|
+
listener = threading.Thread(
|
|
30
|
+
target=_listen_for_keyboard_thread,
|
|
31
|
+
args=(cancel_event, emit),
|
|
32
|
+
name="kimi-cli-keyboard-listener",
|
|
33
|
+
daemon=True,
|
|
34
|
+
)
|
|
35
|
+
listener.start()
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
while True:
|
|
39
|
+
yield await queue.get()
|
|
40
|
+
finally:
|
|
41
|
+
cancel_event.set()
|
|
42
|
+
if listener.is_alive():
|
|
43
|
+
await asyncio.to_thread(listener.join)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _listen_for_keyboard_thread(
|
|
47
|
+
cancel: threading.Event,
|
|
48
|
+
emit: Callable[[KeyEvent], None],
|
|
49
|
+
) -> None:
|
|
50
|
+
# make stdin raw and non-blocking
|
|
51
|
+
fd = sys.stdin.fileno()
|
|
52
|
+
oldterm = termios.tcgetattr(fd)
|
|
53
|
+
newattr = termios.tcgetattr(fd)
|
|
54
|
+
newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO
|
|
55
|
+
newattr[6][termios.VMIN] = 0
|
|
56
|
+
newattr[6][termios.VTIME] = 0
|
|
57
|
+
termios.tcsetattr(fd, termios.TCSANOW, newattr)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
while not cancel.is_set():
|
|
61
|
+
try:
|
|
62
|
+
c = sys.stdin.read(1)
|
|
63
|
+
except (OSError, ValueError):
|
|
64
|
+
c = ""
|
|
65
|
+
|
|
66
|
+
if not c:
|
|
67
|
+
if cancel.is_set():
|
|
68
|
+
break
|
|
69
|
+
time.sleep(0.01)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if c == "\x1b":
|
|
73
|
+
sequence = c
|
|
74
|
+
for _ in range(2):
|
|
75
|
+
if cancel.is_set():
|
|
76
|
+
break
|
|
77
|
+
try:
|
|
78
|
+
fragment = sys.stdin.read(1)
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
fragment = ""
|
|
81
|
+
if not fragment:
|
|
82
|
+
break
|
|
83
|
+
sequence += fragment
|
|
84
|
+
if sequence in _ARROW_KEY_MAP:
|
|
85
|
+
break
|
|
86
|
+
|
|
87
|
+
event = _ARROW_KEY_MAP.get(sequence)
|
|
88
|
+
if event is not None:
|
|
89
|
+
emit(event)
|
|
90
|
+
elif sequence == "\x1b":
|
|
91
|
+
emit(KeyEvent.ESCAPE)
|
|
92
|
+
elif c in ("\r", "\n"):
|
|
93
|
+
emit(KeyEvent.ENTER)
|
|
94
|
+
elif c == "\t":
|
|
95
|
+
emit(KeyEvent.TAB)
|
|
96
|
+
finally:
|
|
97
|
+
# restore the terminal settings
|
|
98
|
+
termios.tcsetattr(fd, termios.TCSAFLUSH, oldterm)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_ARROW_KEY_MAP: dict[str, KeyEvent] = {
|
|
102
|
+
"\x1b[A": KeyEvent.UP,
|
|
103
|
+
"\x1b[B": KeyEvent.DOWN,
|
|
104
|
+
"\x1b[C": KeyEvent.RIGHT,
|
|
105
|
+
"\x1b[D": KeyEvent.LEFT,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
|
|
111
|
+
async def dev_main():
|
|
112
|
+
async for event in listen_for_keyboard():
|
|
113
|
+
print(event)
|
|
114
|
+
|
|
115
|
+
asyncio.run(dev_main())
|