python-agent-harness 1.5.6.5__tar.gz → 1.5.6.7__tar.gz

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 (85) hide show
  1. {python_agent_harness-1.5.6.5/python_agent_harness.egg-info → python_agent_harness-1.5.6.7}/PKG-INFO +1 -1
  2. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/pyproject.toml +1 -1
  3. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/__init__.py +1 -1
  4. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/context_manager.py +9 -6
  5. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/models.py +17 -0
  6. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/token_estimator.py +4 -2
  7. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/llm/client.py +15 -8
  8. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/agent.md +0 -15
  9. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/core.py +18 -4
  10. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/subagent.md +0 -14
  11. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/session/config.py +17 -0
  12. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/session/session.py +8 -6
  13. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tui/core.py +56 -10
  14. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tui/input.py +31 -1
  15. python_agent_harness-1.5.6.7/python_agent_harness/tui/mouse.py +406 -0
  16. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tui/render.py +550 -28
  17. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7/python_agent_harness.egg-info}/PKG-INFO +1 -1
  18. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness.egg-info/SOURCES.txt +1 -0
  19. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/LICENSE +0 -0
  20. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/README.md +0 -0
  21. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/__main__.py +0 -0
  22. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/__init__.py +0 -0
  23. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/agent.py +0 -0
  24. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/planmode.py +0 -0
  25. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/subagent.py +0 -0
  26. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/core/tool_runner.py +0 -0
  27. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/__init__.py +0 -0
  28. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/cli.py +0 -0
  29. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/controller.py +0 -0
  30. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/headless.py +0 -0
  31. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/restore.py +0 -0
  32. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/server.py +0 -0
  33. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/entry/view.py +0 -0
  34. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/io/__init__.py +0 -0
  35. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/io/attachments.py +0 -0
  36. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/io/diffrender.py +0 -0
  37. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/io/persistence.py +0 -0
  38. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/io/text_filter.py +0 -0
  39. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/llm/__init__.py +0 -0
  40. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/mcp/__init__.py +0 -0
  41. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/mcp/client.py +0 -0
  42. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/mcp/config.py +0 -0
  43. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/mcp/manager.py +0 -0
  44. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/__init__.py +0 -0
  45. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  46. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/build-switch.md +0 -0
  47. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/commands/explain.md +0 -0
  48. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/compact.md +0 -0
  49. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/initialize.md +0 -0
  50. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/plan-mode.md +0 -0
  51. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/plan.md +0 -0
  52. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/review.md +0 -0
  53. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/summary.md +0 -0
  54. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  55. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/prompts/title.md +0 -0
  56. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/session/__init__.py +0 -0
  57. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/session/commands.py +0 -0
  58. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/__init__.py +0 -0
  59. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/agent_tool.py +0 -0
  60. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/base.py +0 -0
  61. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/bash.py +0 -0
  62. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/diffapply.py +0 -0
  63. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/edit.py +0 -0
  64. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/edit_mac.py +0 -0
  65. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/filesystem.py +0 -0
  66. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/glob.py +0 -0
  67. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/glob_mac.py +0 -0
  68. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/grep.py +0 -0
  69. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/grep_mac.py +0 -0
  70. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/mcp.py +0 -0
  71. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/planexit.py +0 -0
  72. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/question.py +0 -0
  73. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/read.py +0 -0
  74. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/skill.py +0 -0
  75. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/todo.py +0 -0
  76. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tools/write.py +0 -0
  77. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tui/__init__.py +0 -0
  78. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness/tui/commands.py +0 -0
  79. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  80. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness.egg-info/entry_points.txt +0 -0
  81. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness.egg-info/requires.txt +0 -0
  82. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/python_agent_harness.egg-info/top_level.txt +0 -0
  83. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/setup.cfg +0 -0
  84. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/tests/test_invariants.py +0 -0
  85. {python_agent_harness-1.5.6.5 → python_agent_harness-1.5.6.7}/tests/test_scenarios.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.6.5
3
+ Version: 1.5.6.7
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.6.5"
7
+ version = "1.5.6.7"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -7,7 +7,7 @@ from .mcp.config import MCPConfig, MCPServerConfig
7
7
  from .mcp.manager import MCPManager
8
8
  from .session.session import Session
9
9
 
10
- __version__ = "1.5.6.5"
10
+ __version__ = "1.5.6.7"
11
11
 
12
12
  __all__ = [
13
13
  "Client",
@@ -65,10 +65,11 @@ class ContextManager:
65
65
  def compact(self) -> bool:
66
66
  """Compact the conversation; return True on success.
67
67
 
68
- On success the history is replaced by the summary frame followed
69
- by every real user prompt (nudges and other harness-injected
70
- messages excluded), so the model keeps the actual requests; the
71
- last prompt is the resume request for the next round.
68
+ On success the history is replaced by the summary frame, every
69
+ real user prompt (nudges and other harness-injected messages
70
+ excluded), and a trailing resume message telling the model what
71
+ is already done — the summary and the prompts alone end on a
72
+ bare user request, which invites redoing finished work.
72
73
  """
73
74
  loop = self.loop
74
75
  prompts = user_prompt_texts(loop.messages)
@@ -87,8 +88,10 @@ class ContextManager:
87
88
  # stays untouched): it is part of the user turn, never a
88
89
  # system message. Every real user prompt (nudges and other
89
90
  # harness-injected messages excluded) is preserved verbatim
90
- # after the frame, so the model keeps the actual requests.
91
- loop.messages = compacted_messages(summary, prompts)
91
+ # after the frame, so the model keeps the actual requests,
92
+ # and the trailing resume message states that the summarised
93
+ # work is done (injected flag: TUI and filters skip it).
94
+ loop.messages = compacted_messages(summary, prompts, resume=True)
92
95
  # The shared conversation now is the compacted one: mirror it
93
96
  # onto session.last_messages so the TUI (renders from it) and
94
97
  # a later manual /compact start from the summary, not the old
@@ -35,6 +35,23 @@ class AgentMode(enum.Enum):
35
35
  PLAN = "plan"
36
36
 
37
37
 
38
+ def extract_reasoning(obj: dict[str, Any]) -> str:
39
+ """Reasoning text from a backend response object, or "".
40
+
41
+ Backends disagree on the field name: most (DeepSeek, GLM, ...) use
42
+ ``reasoning_content``; Qwen uses ``reasoning``. This is the single
43
+ place that knows the variants — callers (the LLM client's streaming
44
+ and non-streaming parsers, token estimation) must not open-code the
45
+ keys. Non-string values (some backends send a list of parts) are
46
+ dropped, not coerced.
47
+ """
48
+ for key in ("reasoning_content", "reasoning"):
49
+ value = obj.get(key)
50
+ if isinstance(value, str) and value:
51
+ return value
52
+ return ""
53
+
54
+
38
55
  @dataclass
39
56
  class TextPart:
40
57
  """A plain-text content part in a multimodal message."""
@@ -12,6 +12,7 @@ import os
12
12
  import re
13
13
 
14
14
  from ..session import config
15
+ from .models import extract_reasoning
15
16
 
16
17
 
17
18
  def is_cjk_char(c: str) -> bool:
@@ -128,8 +129,9 @@ def payload_text(system: object, messages: list[dict], tools: list[dict]) -> str
128
129
  if isinstance(v, str):
129
130
  buf.append(v)
130
131
  break
131
- reasoning = msg.get("reasoning_content")
132
- if isinstance(reasoning, str):
132
+ # currently unreachable: Message.to_api() never emits reasoning (and strips it from content)
133
+ reasoning = extract_reasoning(msg)
134
+ if reasoning:
133
135
  buf.append(reasoning)
134
136
  for tc in msg.get("tool_calls") or []:
135
137
  fn = tc.get("function", {}) if isinstance(tc, dict) else {}
@@ -24,7 +24,15 @@ from typing import Any, Protocol, runtime_checkable
24
24
 
25
25
  import httpx
26
26
 
27
- from ..core.models import ImagePart, Message, TextPart, ToolCall, ToolSpec, Usage
27
+ from ..core.models import (
28
+ ImagePart,
29
+ Message,
30
+ TextPart,
31
+ ToolCall,
32
+ ToolSpec,
33
+ Usage,
34
+ extract_reasoning,
35
+ )
28
36
  from ..session import config
29
37
 
30
38
 
@@ -875,11 +883,12 @@ class Client:
875
883
  content_parts.append(delta["content"])
876
884
  if on_delta:
877
885
  on_delta(delta["content"])
878
- if delta.get("reasoning_content"):
879
- content_parts.append(delta["reasoning_content"])
880
- reasoning_parts.append(delta["reasoning_content"])
886
+ reasoning = extract_reasoning(delta)
887
+ if reasoning:
888
+ content_parts.append(reasoning)
889
+ reasoning_parts.append(reasoning)
881
890
  if on_delta:
882
- on_delta(delta["reasoning_content"])
891
+ on_delta(reasoning)
883
892
  for tc in delta.get("tool_calls") or []:
884
893
  self._absorb_tool_call(
885
894
  tc_index, self._slot_for(tc_index, tc, 0), tc, on_tool_call
@@ -921,7 +930,7 @@ class Client:
921
930
  tc_index: dict[int, dict[str, Any]] = {}
922
931
  choice = (data.get("choices") or [{}])[0]
923
932
  msg = choice.get("message") or {}
924
- reasoning = msg.get("reasoning_content") or ""
933
+ reasoning = extract_reasoning(msg)
925
934
  content = msg.get("content") or ""
926
935
  # some backends return content as a list of parts (multimodal);
927
936
  # normalize to plain text like Message.text() does, so the
@@ -931,8 +940,6 @@ class Client:
931
940
  p.get("text", "") if isinstance(p, dict) else (p if isinstance(p, str) else "")
932
941
  for p in content
933
942
  )
934
- if not isinstance(reasoning, str):
935
- reasoning = ""
936
943
  # mirror the streaming order: reasoning first, then the answer
937
944
  if reasoning:
938
945
  reasoning_parts.append(reasoning)
@@ -69,21 +69,6 @@ The user will primarily request you perform software engineering tasks. This inc
69
69
  - Batch independent tool calls in a single response to maximize parallelism.
70
70
 
71
71
  # Tool usage instructions
72
- **Specialized Tools vs. Shell Commands (CRITICAL):**
73
- - NEVER use `Bash` for file operations with grep, find, ls, cat, head, tail, sed or awk.
74
- - ALWAYS use: `Glob`, `Grep`, `Read`, `Edit`, `Write`
75
- - Reserve `Bash` EXCLUSIVELY for: git, npm, docker, cargo, make, system services and other non-file commands
76
- - Using bash for file operations violates the tool hierarchy and creates technical debt
77
-
78
- **Tool Selection Hierarchy:**
79
- - File search by name → Use `Glob` (NOT find or ls)
80
- - Directory listing → Use `Glob` with glob pattern `"*"` (not ls)
81
- - Content search → Use `Grep` (NOT grep or rg)
82
- - Read files → Use `Read` (NOT cat/head/tail)
83
- - Edit files → Use `Edit` (NOT sed/awk)
84
- - Write files → Use `Write` (NOT echo >/cat <<EOF)
85
- - System operations → Use `Bash` (for git, npm, docker, etc.)
86
-
87
72
  {{TOOL_INSTRUCTIONS}}
88
73
 
89
74
  # Code References
@@ -500,7 +500,11 @@ def user_prompt_texts(messages: list) -> list[str]:
500
500
  (``remember_user_text`` excludes reminders for title generation —
501
501
  a different purpose, so the filters differ.)
502
502
  """
503
- nudge = config.NUDGE_MESSAGE
503
+ # Harness-injected one-liners replayed verbatim on every compaction
504
+ # would accumulate: the completion nudge, and the resume message the
505
+ # automatic compaction appends (a bare "keep going" carries no task
506
+ # content — the summary already covers the progress it refers to).
507
+ injected = {config.NUDGE_MESSAGE, config.RESUME_MESSAGE}
504
508
  is_reminder: list[bool] = []
505
509
  last_reminder = -1
506
510
  for msg in messages:
@@ -523,7 +527,7 @@ def user_prompt_texts(messages: list) -> list[str]:
523
527
  if _message_role(msg) != "user":
524
528
  continue
525
529
  text = _message_text(msg)
526
- if not text or text == nudge:
530
+ if not text or text in injected:
527
531
  continue
528
532
  if text.startswith(config.COMPACT_HEADER):
529
533
  continue
@@ -587,19 +591,29 @@ def compact_summary(
587
591
  return summary or None
588
592
 
589
593
 
590
- def compacted_messages(summary: str, prompts: list[str]) -> list[Message]:
594
+ def compacted_messages(summary: str, prompts: list[str], resume: bool = False) -> list[Message]:
591
595
  """The post-compaction history: the summary frame as a user message
592
596
  followed by every preserved user prompt, oldest first.
593
597
 
594
598
  The summary lives in the user turn (the system prompt is passed
595
599
  separately and stays untouched); *prompts* are the real user
596
600
  requests (see ``user_prompt_texts``) that must survive compaction.
601
+
602
+ *resume* appends ``config.RESUME_MESSAGE`` (flagged ``injected``, so
603
+ the TUI and the filters skip it) after the prompts: without it the
604
+ rebuilt history ends with a bare user request and the model may redo
605
+ work the summary already records. Only the automatic in-loop
606
+ compaction passes it — the manual /compact command waits for the
607
+ next user message instead.
597
608
  """
598
609
  frame = (config.COMPACT_HEADER + summary + config.COMPACT_SEPARATOR).strip()
599
- return [
610
+ messages = [
600
611
  Message(role="user", content=frame),
601
612
  *[Message(role="user", content=p) for p in prompts],
602
613
  ]
614
+ if resume:
615
+ messages.append(Message(role="user", content=config.RESUME_MESSAGE, injected=True))
616
+ return messages
603
617
 
604
618
 
605
619
  # ---- custom agent discovery -----------------------------------------------
@@ -20,26 +20,12 @@ You are an autonomous subagent. Your role is to independently complete well-defi
20
20
  You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
21
21
 
22
22
  # Tool usage instructions
23
- **Specialized Tools vs. Shell Commands (CRITICAL):**
24
- - NEVER use `Bash` for file operations with grep, find, ls, cat, head, tail, sed or awk.
25
- - ALWAYS use: `Glob`, `Grep`, `Read`, `Edit`, `Write`
26
- - Reserve `Bash` EXCLUSIVELY for: git, npm, docker, cargo, make, system services and other non-file commands
27
- - Using bash for file operations violates the tool hierarchy and creates technical debt
28
23
 
29
24
  **Parallel Tool Execution:**
30
25
  - Call multiple tools in a single response when tasks are independent
31
26
  - Never use placeholders or guess missing parameters
32
27
  - Maximize parallel execution to improve efficiency
33
28
 
34
- **Tool Selection Hierarchy:**
35
- - File search by name → Use `Glob` (NOT find or ls)
36
- - Directory listing → Use `Glob` with glob pattern `"*"` (not ls)
37
- - Content search → Use `Grep` (NOT grep or rg)
38
- - Read files → Use `Read` (NOT cat/head/tail)
39
- - Edit files → Use `Edit` (NOT sed/awk)
40
- - Write files → Use `Write` (NOT echo >/cat <<EOF)
41
- - System operations → Use `Bash` (for git, npm, docker, etc.)
42
-
43
29
  {{TOOL_INSTRUCTIONS}}
44
30
 
45
31
  # Output Requirements
@@ -44,6 +44,18 @@ NUDGE_MESSAGE = (
44
44
  # ---- compaction -----------------------------------------------------------
45
45
  COMPACT_HEADER = "**[Compacted Summary]**\n\n"
46
46
  COMPACT_SEPARATOR = "\n\n---\n\n**[Context compacted]**\n\n---\n\n"
47
+ # Appended after the summary frame + preserved prompts on the AUTOMATIC
48
+ # path only (AgentLoop -> ContextManager.compact). The rebuilt history
49
+ # ends with the original user request, and a bare request reads as "start
50
+ # here", so the model tends to redo work the summary already records. The
51
+ # manual /compact command does NOT inject it: it replaces the history and
52
+ # waits for the next user message.
53
+ RESUME_MESSAGE = (
54
+ "Compaction just finished; the summary above records the work already done. "
55
+ "Do not repeat completed steps or re-run finished tool calls. "
56
+ "Review which parts of the request above are already done "
57
+ "and continue with the unfinished ones."
58
+ )
47
59
 
48
60
  # ---- token calibration ----------------------------------------------------
49
61
  CALIBRATION_MIN = 0.5
@@ -191,6 +203,11 @@ SUBAGENT_EXCLUDED_TOOLS = ("Agent", "Question", "PlanExit", "TodoWrite")
191
203
  TOOL_RESULT_PREVIEW_LINES = 5 # max lines of a tool result shown in the TUI
192
204
  TOOL_RESULT_PREVIEW_CHARS = 500 # max chars of that preview (long single lines)
193
205
 
206
+ # Per-argument value cap for a tool call the user expanded: the full
207
+ # command/payload is shown, but a runaway (e.g. a 200KB argument) is
208
+ # still trimmed so one row cannot flood the panel.
209
+ TOOL_EXPANDED_ARG_CHARS = 4000
210
+
194
211
  # ---- default agent prompts -----------------------------------------------------
195
212
  # Ported system prompts (opencode-style) for the main agent and sub-agents,
196
213
  # bundled with the package (prompts/agent.md, prompts/subagent.md).
@@ -670,14 +670,16 @@ class Session:
670
670
  def remember_user_text(self, messages: list) -> None:
671
671
  """Remember the last real user message for session-title generation.
672
672
 
673
- Skips harness-injected messages (nudges, plan/build-switch
674
- reminders, queued mode prompts — flagged ``injected``) so a
675
- title is never generated from "Review the original user request
676
- and the Task Completion Rules…" or a mode-switch reminder.
673
+ Skips harness-injected messages (nudges, the post-compaction
674
+ resume message, plan/build-switch reminders, queued mode prompts
675
+ — flagged ``injected``; the content check also covers restored
676
+ sessions, where the flag is lost) so a title is never generated
677
+ from "Review the original user request and the Task Completion
678
+ Rules…" or a mode-switch reminder.
677
679
  """
678
- nudge = config.NUDGE_MESSAGE
680
+ injected = {config.NUDGE_MESSAGE, config.RESUME_MESSAGE}
679
681
  for m in reversed(messages):
680
- if m.role == "user" and not m.injected and m.text() != nudge:
682
+ if m.role == "user" and not m.injected and m.text() not in injected:
681
683
  self.store.remember_first_user_message(m.text())
682
684
  break
683
685
 
@@ -32,6 +32,7 @@ from ..session import config
32
32
  from ..session.session import Session
33
33
  from .commands import CommandMixin
34
34
  from .input import InputMixin, SlashCompleter, UiQuestion, _history_path, _make_prompt_session
35
+ from .mouse import MouseSession, apply_typed
35
36
  from .render import RenderMixin, safe_markup
36
37
 
37
38
  # Unexpected UI exceptions in a row (no successful input read in
@@ -81,6 +82,19 @@ class Tui(RenderMixin, InputMixin, CommandMixin):
81
82
  # prompts/agents/ directory; refreshed on each /agent call so files
82
83
  # added at runtime are picked up.
83
84
  self._discovered_agents: dict[str, str] = {}
85
+ # Tool calls the user expanded by clicking their ▶ row: their
86
+ # panel rows carry every argument in full instead of the
87
+ # truncated one-line label.
88
+ self._expanded_tools: set[str] = set()
89
+ # Last rendered frame, so a mouse click can be traced back to
90
+ # the row it landed on.
91
+ self._last_frame: Any = None
92
+ # Mouse session of the run in progress (None outside a run): a
93
+ # click has to ask the terminal where the panel is on screen.
94
+ self._mouse: MouseSession | None = None
95
+ # Text typed while the mouse reader owned the terminal during a
96
+ # run: replayed into the next input prompt instead of dropped.
97
+ self._typed_during_run = ""
84
98
  self.prompt_session = _make_prompt_session(
85
99
  FileHistory(_history_path(str(self._controller.project_dir))),
86
100
  SlashCompleter(lambda: str(self._controller.project_dir)),
@@ -377,6 +391,16 @@ class Tui(RenderMixin, InputMixin, CommandMixin):
377
391
  self.console.print()
378
392
  self._flush()
379
393
 
394
+ def _collect_typed(self, text: str) -> None:
395
+ """Keep text typed while the mouse reader owned the terminal.
396
+
397
+ With mouse reporting on, the run owns stdin in cbreak mode, so
398
+ keystrokes would otherwise be read by the mouse reader and lost.
399
+ They are buffered here and offered as the next prompt's default
400
+ instead (see ``InputMixin._read_multiline``).
401
+ """
402
+ self._typed_during_run = apply_typed(self._typed_during_run, text)
403
+
380
404
  def _abandon_run(self) -> None:
381
405
  """Cancel the active run and release what it holds (UI side)."""
382
406
  self._controller.cancel()
@@ -401,25 +425,47 @@ class Tui(RenderMixin, InputMixin, CommandMixin):
401
425
  the moment new stream text arrives, so the text pushes the
402
426
  scroll immediately instead of waiting for a fixed tick. The
403
427
  short timeout keeps the spinner animating between data bursts.
428
+
429
+ While the panel is live, terminal mouse reporting is on
430
+ (``mouse.MouseSession``) so clicking a ``▶`` tool row expands or
431
+ collapses it. Reporting is switched off whenever the input
432
+ prompt needs the terminal (a pending question) and always on the
433
+ way out, so normal text selection and typing behave as usual
434
+ outside a run.
404
435
  """
436
+ mouse = MouseSession(
437
+ self.console,
438
+ self._on_mouse_click,
439
+ on_text=self._collect_typed,
440
+ )
441
+ self._mouse = mouse
405
442
  with Live(
406
443
  self._render_frame(),
407
444
  console=self.console,
408
445
  refresh_per_second=30,
409
446
  screen=False,
410
447
  ) as live:
411
- while worker.is_alive():
412
- if self.question is not None:
413
- live.stop()
414
- self._ask_question_blocking()
415
- live.start()
416
- continue
417
- self._data_event.wait(timeout=0.1)
418
- self._data_event.clear()
448
+ mouse.enable()
449
+ try:
450
+ while worker.is_alive():
451
+ if self.question is not None:
452
+ live.stop()
453
+ # prompt_toolkit owns stdin here: mouse reports
454
+ # would land in the answer buffer as garbage
455
+ mouse.disable()
456
+ self._ask_question_blocking()
457
+ mouse.enable()
458
+ live.start()
459
+ continue
460
+ self._data_event.wait(timeout=0.1)
461
+ self._data_event.clear()
462
+ live.update(self._render_frame())
463
+ self._flush()
419
464
  live.update(self._render_frame())
420
465
  self._flush()
421
- live.update(self._render_frame())
422
- self._flush()
466
+ finally:
467
+ mouse.disable()
468
+ self._mouse = None
423
469
  # run finished: the last Live frame stays on screen, but the
424
470
  # in-place redraws never reached the scrollback — print the
425
471
  # full conversation so the user can scroll back through it
@@ -381,9 +381,14 @@ class InputMixin:
381
381
  )
382
382
 
383
383
  def _read_multiline(self) -> str | None:
384
+ # Text typed while a run held the terminal (mouse reporting on)
385
+ # is offered as the default buffer content rather than dropped.
386
+ default = self._pending_default()
384
387
  try:
385
388
  with _safe_patch_stdout():
386
- text = self.prompt_session.prompt(self._input_prompt(), multiline=True)
389
+ text = self.prompt_session.prompt(
390
+ self._input_prompt(), multiline=True, default=default
391
+ )
387
392
  except EOFError:
388
393
  # Ctrl-D: quit
389
394
  return None
@@ -393,6 +398,31 @@ class InputMixin:
393
398
  return ""
394
399
  return text
395
400
 
401
+ def _pending_default(self) -> str:
402
+ """Text typed during the last run, consumed once.
403
+
404
+ Line breaks typed during the run are kept between lines, but
405
+ stripped at the ends: pressing Enter while waiting (to "wake"
406
+ the terminal, or to submit) would otherwise seed the prompt
407
+ with blank lines, and leading whitespace would hide a
408
+ ``/command`` from the slash dispatcher.
409
+ """
410
+ typed = getattr(self, "_typed_during_run", "")
411
+ if typed:
412
+ self._typed_during_run = ""
413
+ # Leading blank lines never matter; leading spaces only go in
414
+ # front of a "/command" (they would hide it from dispatch), so
415
+ # pasted indented code keeps its first line's indentation. At
416
+ # the end only line breaks go -- a trailing space is part of a
417
+ # sentence the user goes on typing.
418
+ lines = typed.split("\n")
419
+ while lines and not lines[0].strip():
420
+ lines.pop(0)
421
+ if lines and lines[0].lstrip().startswith("/"):
422
+ lines[0] = lines[0].lstrip()
423
+ typed = "\n".join(lines).rstrip("\n")
424
+ return typed if typed.strip() else ""
425
+
396
426
  def _ask_question_blocking(self) -> None:
397
427
  """Answer the pending question on the main thread.
398
428