kimi-cli 0.41__py3-none-any.whl → 0.43__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.

Files changed (42) hide show
  1. kimi_cli/CHANGELOG.md +28 -0
  2. kimi_cli/__init__.py +169 -102
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agentspec.py +19 -8
  5. kimi_cli/cli.py +51 -37
  6. kimi_cli/config.py +33 -14
  7. kimi_cli/exception.py +16 -0
  8. kimi_cli/llm.py +31 -3
  9. kimi_cli/metadata.py +5 -68
  10. kimi_cli/session.py +81 -0
  11. kimi_cli/soul/__init__.py +22 -4
  12. kimi_cli/soul/agent.py +18 -23
  13. kimi_cli/soul/context.py +0 -5
  14. kimi_cli/soul/kimisoul.py +40 -25
  15. kimi_cli/soul/message.py +1 -1
  16. kimi_cli/soul/{globals.py → runtime.py} +13 -11
  17. kimi_cli/tools/file/glob.py +1 -1
  18. kimi_cli/tools/file/patch.py +1 -1
  19. kimi_cli/tools/file/read.py +1 -1
  20. kimi_cli/tools/file/replace.py +1 -1
  21. kimi_cli/tools/file/write.py +1 -1
  22. kimi_cli/tools/task/__init__.py +29 -21
  23. kimi_cli/tools/web/search.py +3 -0
  24. kimi_cli/ui/acp/__init__.py +24 -28
  25. kimi_cli/ui/print/__init__.py +27 -30
  26. kimi_cli/ui/shell/__init__.py +58 -42
  27. kimi_cli/ui/shell/keyboard.py +82 -14
  28. kimi_cli/ui/shell/metacmd.py +3 -8
  29. kimi_cli/ui/shell/prompt.py +208 -6
  30. kimi_cli/ui/shell/replay.py +104 -0
  31. kimi_cli/ui/shell/visualize.py +54 -57
  32. kimi_cli/utils/message.py +14 -0
  33. kimi_cli/utils/signals.py +41 -0
  34. kimi_cli/utils/string.py +8 -0
  35. kimi_cli/wire/__init__.py +13 -0
  36. {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/METADATA +21 -20
  37. {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/RECORD +41 -38
  38. kimi_cli/agents/koder/README.md +0 -3
  39. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  40. /kimi_cli/agents/{koder → default}/system.md +0 -0
  41. {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/WHEEL +0 -0
  42. {kimi_cli-0.41.dist-info → kimi_cli-0.43.dist-info}/entry_points.txt +0 -0
kimi_cli/soul/kimisoul.py CHANGED
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING
6
6
  import kosong
7
7
  import tenacity
8
8
  from kosong import StepResult
9
- from kosong.base.message import Message
9
+ from kosong.base.message import ContentPart, ImageURLPart, Message
10
10
  from kosong.chat_provider import (
11
11
  APIConnectionError,
12
12
  APIStatusError,
@@ -16,13 +16,19 @@ from kosong.chat_provider import (
16
16
  from kosong.tooling import ToolResult
17
17
  from tenacity import RetryCallState, retry_if_exception, stop_after_attempt, wait_exponential_jitter
18
18
 
19
- from kimi_cli.config import LoopControl
20
- from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul, StatusSnapshot, wire_send
19
+ from kimi_cli.soul import (
20
+ LLMNotSet,
21
+ LLMNotSupported,
22
+ MaxStepsReached,
23
+ Soul,
24
+ StatusSnapshot,
25
+ wire_send,
26
+ )
21
27
  from kimi_cli.soul.agent import Agent
22
28
  from kimi_cli.soul.compaction import SimpleCompaction
23
29
  from kimi_cli.soul.context import Context
24
- from kimi_cli.soul.globals import AgentGlobals
25
30
  from kimi_cli.soul.message import system, tool_result_to_messages
31
+ from kimi_cli.soul.runtime import Runtime
26
32
  from kimi_cli.tools.dmail import NAME as SendDMail_NAME
27
33
  from kimi_cli.tools.utils import ToolRejectedError
28
34
  from kimi_cli.utils.logging import logger
@@ -43,30 +49,28 @@ class KimiSoul(Soul):
43
49
  def __init__(
44
50
  self,
45
51
  agent: Agent,
46
- agent_globals: AgentGlobals,
52
+ runtime: Runtime,
47
53
  *,
48
54
  context: Context,
49
- loop_control: LoopControl,
50
55
  ):
51
56
  """
52
57
  Initialize the soul.
53
58
 
54
59
  Args:
55
60
  agent (Agent): The agent to run.
56
- agent_globals (AgentGlobals): Global states and parameters.
61
+ runtime (Runtime): Runtime parameters and states.
57
62
  context (Context): The context of the agent.
58
- loop_control (LoopControl): The control parameters for the agent loop.
59
63
  """
60
64
  self._agent = agent
61
- self._agent_globals = agent_globals
62
- self._denwa_renji = agent_globals.denwa_renji
63
- self._approval = agent_globals.approval
65
+ self._runtime = runtime
66
+ self._denwa_renji = runtime.denwa_renji
67
+ self._approval = runtime.approval
64
68
  self._context = context
65
- self._loop_control = loop_control
69
+ self._loop_control = runtime.config.loop_control
66
70
  self._compaction = SimpleCompaction() # TODO: maybe configurable and composable
67
71
  self._reserved_tokens = RESERVED_TOKENS
68
- if self._agent_globals.llm is not None:
69
- assert self._reserved_tokens <= self._agent_globals.llm.max_context_size
72
+ if self._runtime.llm is not None:
73
+ assert self._reserved_tokens <= self._runtime.llm.max_context_size
70
74
 
71
75
  for tool in agent.toolset.tools:
72
76
  if tool.name == SendDMail_NAME:
@@ -81,25 +85,36 @@ class KimiSoul(Soul):
81
85
 
82
86
  @property
83
87
  def model(self) -> str:
84
- return self._agent_globals.llm.chat_provider.model_name if self._agent_globals.llm else ""
88
+ return self._runtime.llm.chat_provider.model_name if self._runtime.llm else ""
85
89
 
86
90
  @property
87
91
  def status(self) -> StatusSnapshot:
88
92
  return StatusSnapshot(context_usage=self._context_usage)
89
93
 
94
+ @property
95
+ def context(self) -> Context:
96
+ return self._context
97
+
90
98
  @property
91
99
  def _context_usage(self) -> float:
92
- if self._agent_globals.llm is not None:
93
- return self._context.token_count / self._agent_globals.llm.max_context_size
100
+ if self._runtime.llm is not None:
101
+ return self._context.token_count / self._runtime.llm.max_context_size
94
102
  return 0.0
95
103
 
96
104
  async def _checkpoint(self):
97
105
  await self._context.checkpoint(self._checkpoint_with_user_message)
98
106
 
99
- async def run(self, user_input: str):
100
- if self._agent_globals.llm is None:
107
+ async def run(self, user_input: str | list[ContentPart]):
108
+ if self._runtime.llm is None:
101
109
  raise LLMNotSet()
102
110
 
111
+ if (
112
+ isinstance(user_input, list)
113
+ and any(isinstance(part, ImageURLPart) for part in user_input)
114
+ and not self._runtime.llm.supports_image_in
115
+ ):
116
+ raise LLMNotSupported(self._runtime.llm, ["image_in"])
117
+
103
118
  await self._checkpoint() # this creates the checkpoint 0 on first run
104
119
  await self._context.append_message(Message(role="user", content=user_input))
105
120
  logger.debug("Appended user message to context")
@@ -107,7 +122,7 @@ class KimiSoul(Soul):
107
122
 
108
123
  async def _agent_loop(self):
109
124
  """The main agent loop for one run."""
110
- assert self._agent_globals.llm is not None
125
+ assert self._runtime.llm is not None
111
126
 
112
127
  async def _pipe_approval_to_wire():
113
128
  while True:
@@ -126,7 +141,7 @@ class KimiSoul(Soul):
126
141
  # compact the context if needed
127
142
  if (
128
143
  self._context.token_count + self._reserved_tokens
129
- >= self._agent_globals.llm.max_context_size
144
+ >= self._runtime.llm.max_context_size
130
145
  ):
131
146
  logger.info("Context too long, compacting...")
132
147
  wire_send(CompactionBegin())
@@ -159,8 +174,8 @@ class KimiSoul(Soul):
159
174
  async def _step(self) -> bool:
160
175
  """Run an single step and return whether the run should be stopped."""
161
176
  # already checked in `run`
162
- assert self._agent_globals.llm is not None
163
- chat_provider = self._agent_globals.llm.chat_provider
177
+ assert self._runtime.llm is not None
178
+ chat_provider = self._runtime.llm.chat_provider
164
179
 
165
180
  @tenacity.retry(
166
181
  retry=retry_if_exception(self._is_retryable_error),
@@ -255,9 +270,9 @@ class KimiSoul(Soul):
255
270
  reraise=True,
256
271
  )
257
272
  async def _compact_with_retry() -> Sequence[Message]:
258
- if self._agent_globals.llm is None:
273
+ if self._runtime.llm is None:
259
274
  raise LLMNotSet()
260
- return await self._compaction.compact(self._context.history, self._agent_globals.llm)
275
+ return await self._compaction.compact(self._context.history, self._runtime.llm)
261
276
 
262
277
  compacted_messages = await _compact_with_retry()
263
278
  await self._context.revert_to(0)
kimi_cli/soul/message.py CHANGED
@@ -14,7 +14,7 @@ def tool_result_to_messages(tool_result: ToolResult) -> list[Message]:
14
14
  message = tool_result.result.message
15
15
  if isinstance(tool_result.result, ToolRuntimeError):
16
16
  message += "\nThis is an unexpected error and the tool is probably not working."
17
- content: list[ContentPart] = [system(message)]
17
+ content: list[ContentPart] = [system(f"ERROR: {message}")]
18
18
  if tool_result.result.output:
19
19
  content.append(TextPart(text=tool_result.result.output))
20
20
  return [
@@ -6,7 +6,7 @@ from typing import NamedTuple
6
6
 
7
7
  from kimi_cli.config import Config
8
8
  from kimi_cli.llm import LLM
9
- from kimi_cli.metadata import Session
9
+ from kimi_cli.session import Session
10
10
  from kimi_cli.soul.approval import Approval
11
11
  from kimi_cli.soul.denwarenji import DenwaRenji
12
12
  from kimi_cli.utils.logging import logger
@@ -58,8 +58,8 @@ def _list_work_dir(work_dir: Path) -> str:
58
58
  return ls.stdout.strip()
59
59
 
60
60
 
61
- class AgentGlobals(NamedTuple):
62
- """Agent globals."""
61
+ class Runtime(NamedTuple):
62
+ """Agent runtime."""
63
63
 
64
64
  config: Config
65
65
  llm: LLM | None
@@ -68,22 +68,24 @@ class AgentGlobals(NamedTuple):
68
68
  denwa_renji: DenwaRenji
69
69
  approval: Approval
70
70
 
71
- @classmethod
71
+ @staticmethod
72
72
  async def create(
73
- cls, config: Config, llm: LLM | None, session: Session, yolo: bool
74
- ) -> "AgentGlobals":
75
- work_dir = Path(session.work_dir.path)
73
+ config: Config,
74
+ llm: LLM | None,
75
+ session: Session,
76
+ yolo: bool,
77
+ ) -> "Runtime":
76
78
  # FIXME: do these asynchronously
77
- ls_output = _list_work_dir(work_dir)
78
- agents_md = load_agents_md(work_dir) or ""
79
+ ls_output = _list_work_dir(session.work_dir)
80
+ agents_md = load_agents_md(session.work_dir) or ""
79
81
 
80
- return cls(
82
+ return Runtime(
81
83
  config=config,
82
84
  llm=llm,
83
85
  session=session,
84
86
  builtin_args=BuiltinSystemPromptArgs(
85
87
  KIMI_NOW=datetime.now().astimezone().isoformat(),
86
- KIMI_WORK_DIR=work_dir,
88
+ KIMI_WORK_DIR=session.work_dir,
87
89
  KIMI_WORK_DIR_LS=ls_output,
88
90
  KIMI_AGENTS_MD=agents_md,
89
91
  ),
@@ -8,7 +8,7 @@ import aiofiles.os
8
8
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
9
9
  from pydantic import BaseModel, Field
10
10
 
11
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
11
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
12
12
  from kimi_cli.tools.utils import load_desc
13
13
 
14
14
  MAX_MATCHES = 1000
@@ -7,7 +7,7 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
7
7
  from pydantic import BaseModel, Field
8
8
 
9
9
  from kimi_cli.soul.approval import Approval
10
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
10
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
11
11
  from kimi_cli.tools.file import FileActions
12
12
  from kimi_cli.tools.utils import ToolRejectedError
13
13
 
@@ -5,7 +5,7 @@ import aiofiles
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
8
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
9
9
  from kimi_cli.tools.utils import load_desc, truncate_line
10
10
 
11
11
  MAX_LINES = 1000
@@ -6,7 +6,7 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from kimi_cli.soul.approval import Approval
9
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
10
10
  from kimi_cli.tools.file import FileActions
11
11
  from kimi_cli.tools.utils import ToolRejectedError
12
12
 
@@ -6,7 +6,7 @@ from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
8
  from kimi_cli.soul.approval import Approval
9
- from kimi_cli.soul.globals import BuiltinSystemPromptArgs
9
+ from kimi_cli.soul.runtime import BuiltinSystemPromptArgs
10
10
  from kimi_cli.tools.file import FileActions
11
11
  from kimi_cli.tools.utils import ToolRejectedError
12
12
 
@@ -5,12 +5,12 @@ from typing import override
5
5
  from kosong.tooling import CallableTool2, ToolError, ToolOk, ToolReturnType
6
6
  from pydantic import BaseModel, Field
7
7
 
8
- from kimi_cli.agentspec import ResolvedAgentSpec
8
+ from kimi_cli.agentspec import ResolvedAgentSpec, SubagentSpec
9
9
  from kimi_cli.soul import MaxStepsReached, get_wire_or_none, run_soul
10
10
  from kimi_cli.soul.agent import Agent, load_agent
11
11
  from kimi_cli.soul.context import Context
12
- from kimi_cli.soul.globals import AgentGlobals
13
12
  from kimi_cli.soul.kimisoul import KimiSoul
13
+ from kimi_cli.soul.runtime import Runtime
14
14
  from kimi_cli.tools.utils import load_desc
15
15
  from kimi_cli.utils.message import message_extract_text
16
16
  from kimi_cli.utils.path import next_available_rotation
@@ -49,28 +49,37 @@ class Task(CallableTool2[Params]):
49
49
  name: str = "Task"
50
50
  params: type[Params] = Params
51
51
 
52
- def __init__(self, agent_spec: ResolvedAgentSpec, agent_globals: AgentGlobals, **kwargs):
53
- subagents: dict[str, Agent] = {}
54
- descs = []
55
-
56
- # load all subagents
57
- for name, spec in agent_spec.subagents.items():
58
- subagents[name] = load_agent(spec.path, agent_globals)
59
- descs.append(f"- `{name}`: {spec.description}")
60
-
52
+ def __init__(self, agent_spec: ResolvedAgentSpec, runtime: Runtime, **kwargs):
61
53
  super().__init__(
62
54
  description=load_desc(
63
55
  Path(__file__).parent / "task.md",
64
56
  {
65
- "SUBAGENTS_MD": "\n".join(descs),
57
+ "SUBAGENTS_MD": "\n".join(
58
+ f"- `{name}`: {spec.description}"
59
+ for name, spec in agent_spec.subagents.items()
60
+ ),
66
61
  },
67
62
  ),
68
63
  **kwargs,
69
64
  )
70
65
 
71
- self._agent_globals = agent_globals
72
- self._session = agent_globals.session
73
- self._subagents = subagents
66
+ self._runtime = runtime
67
+ self._session = runtime.session
68
+ self._subagents: dict[str, Agent] = {}
69
+
70
+ try:
71
+ loop = asyncio.get_running_loop()
72
+ self._load_task = loop.create_task(self._load_subagents(agent_spec.subagents))
73
+ except RuntimeError:
74
+ # In case there's no running event loop, e.g., during synchronous tests
75
+ self._load_task = None
76
+ asyncio.run(self._load_subagents(agent_spec.subagents))
77
+
78
+ async def _load_subagents(self, subagent_specs: dict[str, SubagentSpec]) -> None:
79
+ """Load all subagents specified in the agent spec."""
80
+ for name, spec in subagent_specs.items():
81
+ agent = await load_agent(spec.path, self._runtime, mcp_configs=[])
82
+ self._subagents[name] = agent
74
83
 
75
84
  async def _get_subagent_history_file(self) -> Path:
76
85
  """Generate a unique history file path for subagent."""
@@ -85,6 +94,10 @@ class Task(CallableTool2[Params]):
85
94
 
86
95
  @override
87
96
  async def __call__(self, params: Params) -> ToolReturnType:
97
+ if self._load_task is not None:
98
+ await self._load_task
99
+ self._load_task = None
100
+
88
101
  if params.subagent_name not in self._subagents:
89
102
  return ToolError(
90
103
  message=f"Subagent not found: {params.subagent_name}",
@@ -117,12 +130,7 @@ class Task(CallableTool2[Params]):
117
130
 
118
131
  subagent_history_file = await self._get_subagent_history_file()
119
132
  context = Context(file_backend=subagent_history_file)
120
- soul = KimiSoul(
121
- agent,
122
- agent_globals=self._agent_globals,
123
- context=context,
124
- loop_control=self._agent_globals.config.loop_control,
125
- )
133
+ soul = KimiSoul(agent, runtime=self._runtime, context=context)
126
134
 
127
135
  try:
128
136
  await run_soul(soul, prompt, _ui_loop_fn, asyncio.Event())
@@ -44,9 +44,11 @@ class SearchWeb(CallableTool2[Params]):
44
44
  if config.services.moonshot_search is not None:
45
45
  self._base_url = config.services.moonshot_search.base_url
46
46
  self._api_key = config.services.moonshot_search.api_key.get_secret_value()
47
+ self._custom_headers = config.services.moonshot_search.custom_headers or {}
47
48
  else:
48
49
  self._base_url = ""
49
50
  self._api_key = ""
51
+ self._custom_headers = {}
50
52
 
51
53
  @override
52
54
  async def __call__(self, params: Params) -> ToolReturnType:
@@ -69,6 +71,7 @@ class SearchWeb(CallableTool2[Params]):
69
71
  "User-Agent": USER_AGENT,
70
72
  "Authorization": f"Bearer {self._api_key}",
71
73
  "X-Msh-Tool-Call-Id": tool_call.id,
74
+ **self._custom_headers,
72
75
  },
73
76
  json={
74
77
  "text_query": params.query,
@@ -172,33 +172,29 @@ class ACPAgent:
172
172
  self.run_state.cancel_event.set()
173
173
 
174
174
  async def _stream_events(self, wire: WireUISide):
175
- try:
176
- # expect a StepBegin
177
- assert isinstance(await wire.receive(), StepBegin)
178
-
179
- while True:
180
- msg = await wire.receive()
181
-
182
- if isinstance(msg, TextPart):
183
- await self._send_text(msg.text)
184
- elif isinstance(msg, ContentPart):
185
- logger.warning("Unsupported content part: {part}", part=msg)
186
- await self._send_text(f"[{msg.__class__.__name__}]")
187
- elif isinstance(msg, ToolCall):
188
- await self._send_tool_call(msg)
189
- elif isinstance(msg, ToolCallPart):
190
- await self._send_tool_call_part(msg)
191
- elif isinstance(msg, ToolResult):
192
- await self._send_tool_result(msg)
193
- elif isinstance(msg, ApprovalRequest):
194
- await self._handle_approval_request(msg)
195
- elif isinstance(msg, StatusUpdate):
196
- # TODO: stream status if needed
197
- pass
198
- elif isinstance(msg, StepInterrupted):
199
- break
200
- except asyncio.QueueShutDown:
201
- logger.debug("Event stream loop shutting down")
175
+ assert isinstance(await wire.receive(), StepBegin)
176
+
177
+ while True:
178
+ msg = await wire.receive()
179
+
180
+ if isinstance(msg, TextPart):
181
+ await self._send_text(msg.text)
182
+ elif isinstance(msg, ContentPart):
183
+ logger.warning("Unsupported content part: {part}", part=msg)
184
+ await self._send_text(f"[{msg.__class__.__name__}]")
185
+ elif isinstance(msg, ToolCall):
186
+ await self._send_tool_call(msg)
187
+ elif isinstance(msg, ToolCallPart):
188
+ await self._send_tool_call_part(msg)
189
+ elif isinstance(msg, ToolResult):
190
+ await self._send_tool_result(msg)
191
+ elif isinstance(msg, ApprovalRequest):
192
+ await self._handle_approval_request(msg)
193
+ elif isinstance(msg, StatusUpdate):
194
+ # TODO: stream status if needed
195
+ pass
196
+ elif isinstance(msg, StepInterrupted):
197
+ break
202
198
 
203
199
  async def _send_text(self, text: str):
204
200
  """Send text chunk to client."""
@@ -321,7 +317,7 @@ class ACPAgent:
321
317
  # Create permission request with options
322
318
  permission_request = acp.RequestPermissionRequest(
323
319
  sessionId=self.session_id,
324
- toolCall=acp.schema.ToolCallUpdate(
320
+ toolCall=acp.schema.ToolCall(
325
321
  toolCallId=state.acp_tool_call_id,
326
322
  content=[
327
323
  acp.schema.ContentToolCallContent(
@@ -1,6 +1,5 @@
1
1
  import asyncio
2
2
  import json
3
- import signal
4
3
  import sys
5
4
  from functools import partial
6
5
  from pathlib import Path
@@ -9,10 +8,12 @@ from typing import Literal
9
8
  import aiofiles
10
9
  from kosong.base.message import Message
11
10
  from kosong.chat_provider import ChatProviderError
11
+ from rich import print
12
12
 
13
13
  from kimi_cli.soul import LLMNotSet, MaxStepsReached, RunCancelled, Soul, run_soul
14
14
  from kimi_cli.utils.logging import logger
15
15
  from kimi_cli.utils.message import message_extract_text
16
+ from kimi_cli.utils.signals import install_sigint_handler
16
17
  from kimi_cli.wire import WireUISide
17
18
  from kimi_cli.wire.message import StepInterrupted
18
19
 
@@ -51,7 +52,7 @@ class PrintApp:
51
52
  cancel_event.set()
52
53
 
53
54
  loop = asyncio.get_running_loop()
54
- loop.add_signal_handler(signal.SIGINT, _handler)
55
+ remove_sigint = install_sigint_handler(loop, _handler)
55
56
 
56
57
  if command is None and not sys.stdin.isatty() and self.input_format == "text":
57
58
  command = sys.stdin.read().strip()
@@ -98,7 +99,7 @@ class PrintApp:
98
99
  print(f"Unknown error: {e}")
99
100
  raise
100
101
  finally:
101
- loop.remove_signal_handler(signal.SIGINT)
102
+ remove_sigint()
102
103
  return False
103
104
 
104
105
  def _read_next_command(self) -> str | None:
@@ -127,33 +128,29 @@ class PrintApp:
127
128
  logger.warning("Ignoring invalid user message: {json_line}", json_line=json_line)
128
129
 
129
130
  async def _visualize_text(self, wire: WireUISide):
130
- try:
131
- while True:
132
- msg = await wire.receive()
133
- print(msg)
134
- if isinstance(msg, StepInterrupted):
135
- break
136
- except asyncio.QueueShutDown:
137
- logger.debug("Visualization loop shutting down")
131
+ while True:
132
+ msg = await wire.receive()
133
+ print(msg)
134
+ if isinstance(msg, StepInterrupted):
135
+ break
138
136
 
139
137
  async def _visualize_stream_json(self, wire: WireUISide, start_position: int):
140
138
  # TODO: be aware of context compaction
141
- try:
142
- async with aiofiles.open(self.context_file, encoding="utf-8") as f:
143
- await f.seek(start_position)
144
- while True:
145
- should_end = False
146
- while wire._queue.qsize() > 0:
147
- msg = wire._queue.get_nowait()
148
- if isinstance(msg, StepInterrupted):
149
- should_end = True
150
-
151
- line = await f.readline()
152
- if not line:
153
- if should_end:
154
- break
155
- await asyncio.sleep(0.1)
156
- continue
157
- print(line, end="")
158
- except asyncio.QueueShutDown:
159
- logger.debug("Visualization loop shutting down")
139
+ # FIXME: this is only a temporary impl, may miss the last lines of the context file
140
+ if not self.context_file.exists():
141
+ self.context_file.touch()
142
+ async with aiofiles.open(self.context_file, encoding="utf-8") as f:
143
+ await f.seek(start_position)
144
+ while True:
145
+ should_end = False
146
+ while (msg := wire.receive_nowait()) is not None:
147
+ if isinstance(msg, StepInterrupted):
148
+ should_end = True
149
+
150
+ line = await f.readline()
151
+ if not line:
152
+ if should_end:
153
+ break
154
+ await asyncio.sleep(0.1)
155
+ continue
156
+ print(line, end="")