noah-code 0.4.0__tar.gz → 0.4.1__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 (139) hide show
  1. {noah_code-0.4.0 → noah_code-0.4.1}/PKG-INFO +1 -1
  2. noah_code-0.4.1/docs/releases/v0.4.1.md +54 -0
  3. {noah_code-0.4.0 → noah_code-0.4.1}/pyproject.toml +1 -1
  4. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/__init__.py +1 -1
  5. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/agent.py +13 -3
  6. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/cli.py +10 -5
  7. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/commands.py +76 -22
  8. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/composer.py +4 -1
  9. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/config.py +57 -0
  10. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/host.py +153 -1
  11. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/providers.py +7 -0
  12. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/steer.py +45 -1
  13. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/process_tools.py +10 -2
  14. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/workspace_tools.py +86 -10
  15. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/ui/textual.css +199 -2
  16. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/ui/textual_app.py +1421 -120
  17. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_agent_security.py +7 -0
  18. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_cache_context.py +2 -1
  19. noah_code-0.4.1/tests/test_commands.py +65 -0
  20. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_composer.py +2 -0
  21. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_config.py +16 -0
  22. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_host.py +51 -1
  23. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_process_tools.py +23 -0
  24. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_providers.py +13 -0
  25. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_steer.py +27 -1
  26. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_textual_tui.py +483 -17
  27. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_workspace_tools.py +41 -0
  28. {noah_code-0.4.0 → noah_code-0.4.1}/uv.lock +1 -1
  29. {noah_code-0.4.0 → noah_code-0.4.1}/.github/workflows/ci.yml +0 -0
  30. {noah_code-0.4.0 → noah_code-0.4.1}/.github/workflows/release.yml +0 -0
  31. {noah_code-0.4.0 → noah_code-0.4.1}/.gitignore +0 -0
  32. {noah_code-0.4.0 → noah_code-0.4.1}/.pre-commit-config.yaml +0 -0
  33. {noah_code-0.4.0 → noah_code-0.4.1}/README.md +0 -0
  34. {noah_code-0.4.0 → noah_code-0.4.1}/docs/assets/noah-in-action.svg +0 -0
  35. {noah_code-0.4.0 → noah_code-0.4.1}/docs/assets/noah-logo.svg +0 -0
  36. {noah_code-0.4.0 → noah_code-0.4.1}/docs/configuration.md +0 -0
  37. {noah_code-0.4.0 → noah_code-0.4.1}/docs/development.md +0 -0
  38. {noah_code-0.4.0 → noah_code-0.4.1}/docs/extensions.md +0 -0
  39. {noah_code-0.4.0 → noah_code-0.4.1}/docs/interactive-reference.md +0 -0
  40. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.1.0.md +0 -0
  41. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.1.1.md +0 -0
  42. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.2.0.md +0 -0
  43. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.2.1.md +0 -0
  44. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.2.2.md +0 -0
  45. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.2.3.md +0 -0
  46. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.2.4.md +0 -0
  47. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.3.0.md +0 -0
  48. {noah_code-0.4.0 → noah_code-0.4.1}/docs/releases/v0.4.0.md +0 -0
  49. {noah_code-0.4.0 → noah_code-0.4.1}/docs/reliability.md +0 -0
  50. {noah_code-0.4.0 → noah_code-0.4.1}/docs/security.md +0 -0
  51. {noah_code-0.4.0 → noah_code-0.4.1}/install.sh +0 -0
  52. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/__main__.py +0 -0
  53. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/agents.py +0 -0
  54. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/approvals.py +0 -0
  55. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/budget.py +0 -0
  56. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/checkpoints.py +0 -0
  57. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/credentials.py +0 -0
  58. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/custom_commands.py +0 -0
  59. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/event_bridge.py +0 -0
  60. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/events.py +0 -0
  61. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/github.py +0 -0
  62. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/hooks.py +0 -0
  63. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/llm.py +0 -0
  64. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/llm_replies.py +0 -0
  65. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/macos_sandbox.py +0 -0
  66. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/mcp_setup.py +0 -0
  67. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/nooa_compat.py +0 -0
  68. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/permissions.py +0 -0
  69. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/predict.py +0 -0
  70. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/project_notes.py +0 -0
  71. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/redaction.py +0 -0
  72. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/runtime_state.py +0 -0
  73. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/secure_files.py +0 -0
  74. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/sessions.py +0 -0
  75. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/skills_setup.py +0 -0
  76. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/snapshots.py +0 -0
  77. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/summarization.py +0 -0
  78. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/telemetry.py +0 -0
  79. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/themes.py +0 -0
  80. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tool_output.py +0 -0
  81. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/__init__.py +0 -0
  82. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/diff_tools.py +0 -0
  83. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/git_tools.py +0 -0
  84. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/github_tools.py +0 -0
  85. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/lsp_tools.py +0 -0
  86. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/media_tools.py +0 -0
  87. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/memory_tools.py +0 -0
  88. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/plan_tools.py +0 -0
  89. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/question_tools.py +0 -0
  90. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/task_tools.py +0 -0
  91. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/tools/web_tools.py +0 -0
  92. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/ui/__init__.py +0 -0
  93. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/ui/console.py +0 -0
  94. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/ui/protocol.py +0 -0
  95. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/updates.py +0 -0
  96. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/usage.py +0 -0
  97. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/workspace.py +0 -0
  98. {noah_code-0.4.0 → noah_code-0.4.1}/src/noah_code/worktree.py +0 -0
  99. {noah_code-0.4.0 → noah_code-0.4.1}/tests/conftest.py +0 -0
  100. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_agents.py +0 -0
  101. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_approvals.py +0 -0
  102. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_budget.py +0 -0
  103. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_checkpoints.py +0 -0
  104. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_cli.py +0 -0
  105. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_compaction_eviction.py +0 -0
  106. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_console_ui.py +0 -0
  107. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_credentials.py +0 -0
  108. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_custom_commands.py +0 -0
  109. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_diff_tools.py +0 -0
  110. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_efficiency.py +0 -0
  111. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_event_bridge.py +0 -0
  112. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_event_bridge_and_shell.py +0 -0
  113. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_git_tools.py +0 -0
  114. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_github.py +0 -0
  115. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_hooks.py +0 -0
  116. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_installer.py +0 -0
  117. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_llm.py +0 -0
  118. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_llm_replies.py +0 -0
  119. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_lsp_tools.py +0 -0
  120. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_mcp_setup.py +0 -0
  121. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_permissions.py +0 -0
  122. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_plan_memory_tools.py +0 -0
  123. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_project_notes.py +0 -0
  124. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_prompt_scorecard.py +0 -0
  125. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_question_tools.py +0 -0
  126. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_redaction.py +0 -0
  127. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_run_exit.py +0 -0
  128. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_runtime_state.py +0 -0
  129. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_sessions.py +0 -0
  130. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_skills_setup.py +0 -0
  131. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_snapshots.py +0 -0
  132. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_summarization.py +0 -0
  133. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_task_tools.py +0 -0
  134. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_telemetry.py +0 -0
  135. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_tool_output.py +0 -0
  136. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_updates.py +0 -0
  137. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_wave1_e2e.py +0 -0
  138. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_web_tools.py +0 -0
  139. {noah_code-0.4.0 → noah_code-0.4.1}/tests/test_worktree.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: noah-code
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Noah Code terminal coding agent, built on NVIDIA OO Agents (NOOA)
5
5
  Project-URL: Homepage, https://github.com/skundu42/noah-code
6
6
  Project-URL: Documentation, https://github.com/skundu42/noah-code#readme
@@ -0,0 +1,54 @@
1
+ # Noah Code v0.4.1
2
+
3
+ This release makes Noah's terminal interface easier to learn, calmer during long-running work,
4
+ and more reliable across supported platforms.
5
+
6
+ ## Guided and discoverable UX
7
+
8
+ - Added first-run onboarding that detects missing model configuration and guides users through
9
+ provider setup instead of stopping at an error.
10
+ - Added a searchable, context-aware keyboard help overlay with shortcuts for the currently focused
11
+ panel.
12
+ - Streamlined slash-command discovery into one flat list, removed duplicate command entries, and
13
+ kept labels focused on the commands themselves.
14
+ - Added `/continue` session selection for the ten most recent sessions in the current workspace.
15
+ - Added automatic update notices with the appropriate upgrade command.
16
+
17
+ ## Clearer long-running work
18
+
19
+ - Added tool-aware progress with elapsed time and concise counts for searches and other operations.
20
+ - Added a collapsible task timeline for thinking, tool calls, retries, approvals, and validation
21
+ without adding noise to the transcript.
22
+ - Added context visibility for active files, instructions, project memory, and attachments.
23
+ - Added queue management for pending prompts and attachments, expandable error details, safer undo
24
+ confirmation, and compact completion receipts.
25
+
26
+ ## Faster, steadier TUI
27
+
28
+ - Reworked the working indicator into a continuous left-to-right animation with isolated 80 ms
29
+ loader updates, while elapsed status refreshes remain throttled separately.
30
+ - Added reduced-motion support and semantic states for thinking, running tools, waiting for input or
31
+ approval, cancelling, and errors.
32
+ - Improved live-output following so users can inspect earlier output while new-line counts remain
33
+ visible and can jump back to the latest output on demand.
34
+ - Avoided unnecessary full-screen redraws that caused flashing and visual instability.
35
+
36
+ ## Reliability
37
+
38
+ - Made common search and read requests accept familiar paths, regex arguments, and content helper
39
+ forms more consistently.
40
+ - Hardened loader shutdown against timer/widget teardown races found on Intel macOS CI.
41
+ - Added a direct-child termination fallback when macOS denies an owned process-group signal.
42
+ - Stabilized asynchronous TUI validation across Linux, Apple-silicon macOS, and Intel macOS.
43
+
44
+ ## Upgrade
45
+
46
+ Existing managed installations can run:
47
+
48
+ ```bash
49
+ noah update
50
+ ```
51
+
52
+ New installations can use the one-command installer from the README.
53
+
54
+ **Full changelog:** https://github.com/skundu42/noah-code/compare/v0.4.0...v0.4.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "noah-code"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  description = "Noah Code terminal coding agent, built on NVIDIA OO Agents (NOOA)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12,<3.14"
@@ -1,3 +1,3 @@
1
1
  """Noah Code: terminal coding agent on NVIDIA OO Agents."""
2
2
 
3
- __version__ = "0.4.0"
3
+ __version__ = "0.4.1"
@@ -119,6 +119,7 @@ class _PermissionSandboxedExecutor(SandboxedExecutor):
119
119
  ("ws", "edit"),
120
120
  ("ws", "inspect"),
121
121
  ("ws", "apply_patch"),
122
+ ("ws", "apply_unified_diff"),
122
123
  ("ws", "list"),
123
124
  ("ws", "list_files"),
124
125
  ("ws", "read"),
@@ -146,7 +147,13 @@ class _PermissionSandboxedExecutor(SandboxedExecutor):
146
147
  ("web", "search"),
147
148
  ("task", "list"),
148
149
  ("task", "run"),
150
+ ("task", "run_many"),
151
+ ("task", "collaborate"),
149
152
  ("processes", "input"),
153
+ ("processes", "open_terminal"),
154
+ ("processes", "terminal_run"),
155
+ ("processes", "terminal_status"),
156
+ ("processes", "close_terminal"),
150
157
  ("processes", "logs"),
151
158
  ("processes", "start"),
152
159
  ("processes", "status"),
@@ -389,6 +396,9 @@ tools with `await`. `self.message(text)` is synchronous—never await it.
389
396
  three arguments; two-argument calls are invalid. Prefer one atomic
390
397
  `self.ws.apply_patch(changes)` for a
391
398
  coherent batch; `self.ws.apply_unified_diff` accepts git-style hunks.
399
+ - Common shapes: `ws.search(pattern, path=".", paths=None, regex=True)` returns
400
+ iterable Matches plus `.stdout`; `ws.read(path, lines=None)` returns a Match
401
+ with `.text`/`.content`; `ws.list(pattern="**/*", path=".")` returns paths.
392
402
  - Run commands with `await self.ws.run(command)` and inspect
393
403
  returncode/stdout/stderr. `read_only=True` skips approval only for commands
394
404
  the engine recognizes as read-only (Git inspection, search, listing, text
@@ -418,7 +428,7 @@ when the host was explicitly launched for a throwaway workspace.
418
428
 
419
429
  Sandboxed cells forbid host/system imports (including os, sys, subprocess,
420
430
  shutil, nooa, and noah_code), dynamic execution, input, and attaching callables
421
- to `self`. Use dedicated tools instead; use `doc(obj)` for focused API help.
431
+ to `self`. Use dedicated tools instead; use `doc(self)` for API help.
422
432
 
423
433
  End with exactly one RespondResult: DONE, NEED_INPUT, or WAIT (WAIT requires a
424
434
  registered running job). In code call
@@ -432,8 +442,8 @@ _NOAH_EXECUTION_CONTEXT = """## Execution Context
432
442
  Inside `execute_python`, method parameters and `self` are already in scope and
433
443
  state persists across cells. Always available: `print`, `pprint`, `doc`,
434
444
  `return_result`, `RespondReason`, `asyncio`, and `typing`. Inspect only the API
435
- you need with `doc(self.ws)` or `doc(self.<tool>.<method>)`; Noah's internal
436
- module imports are intentionally not part of the agent-facing contract.
445
+ you need with `doc(self)`; nested sandbox proxies are not introspectable. Noah's
446
+ internal module imports are intentionally not part of the agent-facing contract.
437
447
  """
438
448
 
439
449
  _OBSERVABILITY_EVENT_TYPES = (
@@ -948,13 +948,18 @@ async def _interactive(
948
948
  return code
949
949
  workspace, config, store, meta = prepared
950
950
  if use_tui:
951
+ from noah_code.providers import model_setup_required
952
+
951
953
  host = AgentHost(workspace, config, session_meta=meta, store=store)
952
954
  onboarding_required = (
953
- first_run
954
- and model is None
955
- and meta is None
956
- and not os.environ.get("NOAH_CODE_MODEL")
957
- and config.model == NoahCodeConfig().model
955
+ (
956
+ first_run
957
+ and model is None
958
+ and meta is None
959
+ and not os.environ.get("NOAH_CODE_MODEL")
960
+ and config.model == NoahCodeConfig().model
961
+ )
962
+ or model_setup_required(meta.model if meta is not None else config.model)
958
963
  )
959
964
  try:
960
965
  try:
@@ -25,18 +25,24 @@ class CommandSpec:
25
25
  class CommandSuggestion:
26
26
  invocation: str
27
27
  description: str
28
+ category: str = "Other"
28
29
 
29
30
 
30
31
  BUILTIN_COMMANDS: list[CommandSpec] = [
31
32
  CommandSpec("help", "Show available commands"),
32
33
  CommandSpec("config", "Show every resolved setting or one path", "config [PATH]"),
33
34
  CommandSpec("theme", "Show or switch the interface theme", "theme [NAME]"),
35
+ CommandSpec("animations", "Enable or disable interface motion", "animations [on|off]"),
34
36
  CommandSpec("mode", "Show or switch the active mode", "mode [build|plan]"),
35
- CommandSpec("model", "Configure a provider or switch this session's model", "model [MODEL]"),
37
+ CommandSpec(
38
+ "model",
39
+ "Configure a provider or switch this session's model",
40
+ "model [MODEL | --global MODEL]",
41
+ ),
36
42
  CommandSpec(
37
43
  "reasoning",
38
44
  "Show or set reasoning effort for compatible models",
39
- "reasoning [default|none|minimal|low|medium|high|xhigh]",
45
+ "reasoning [EFFORT | --global EFFORT]",
40
46
  ),
41
47
  CommandSpec(
42
48
  "providers",
@@ -62,10 +68,11 @@ BUILTIN_COMMANDS: list[CommandSpec] = [
62
68
  "Show, save, forget, or clear project conventions",
63
69
  "memory [save|forget|clear]",
64
70
  ),
65
- CommandSpec("continue", "Resume most recent session"),
71
+ CommandSpec("continue", "Choose from the latest ten workspace sessions"),
66
72
  CommandSpec("compact", "Trigger history summarization"),
67
73
  CommandSpec("todos", "Show todo list"),
68
74
  CommandSpec("status", "Show mode/model/session/context"),
75
+ CommandSpec("context", "Inspect files, instructions, memory, and attachments in context"),
69
76
  CommandSpec("health", "Show durable runtime health"),
70
77
  CommandSpec("checkpoints", "List rolling Git worktree checkpoints"),
71
78
  CommandSpec("tokens", "Show token, cache, cost, and latency usage"),
@@ -79,6 +86,8 @@ BUILTIN_COMMANDS: list[CommandSpec] = [
79
86
  CommandSpec("redo", "Redo last undone turn"),
80
87
  CommandSpec("agents", "List built-in and markdown subagents"),
81
88
  CommandSpec("work", "Show live agent, terminal, and background-job work"),
89
+ CommandSpec("timeline", "Inspect the current task timeline"),
90
+ CommandSpec("queue", "Inspect and manage queued prompts and attachments"),
82
91
  CommandSpec("terminals", "List persistent terminal sessions"),
83
92
  CommandSpec("attach", "Attach a workspace file or image to the next turn", "attach [PATH]"),
84
93
  CommandSpec("skills", "Search skills or add a compatible skill folder", "skills [add PATH]"),
@@ -88,6 +97,47 @@ BUILTIN_COMMANDS: list[CommandSpec] = [
88
97
  ]
89
98
 
90
99
 
100
+ _COMMAND_CATEGORIES = {
101
+ "help": "General",
102
+ "config": "Settings",
103
+ "theme": "Settings",
104
+ "animations": "Settings",
105
+ "mode": "Agent",
106
+ "model": "Model",
107
+ "reasoning": "Model",
108
+ "providers": "Model",
109
+ "efficiency": "Model",
110
+ "session": "Session",
111
+ "sessions": "Session",
112
+ "new": "Session",
113
+ "continue": "Session",
114
+ "compact": "Session",
115
+ "worktree": "Git",
116
+ "pr": "Git",
117
+ "diff": "Git",
118
+ "undo": "Git",
119
+ "redo": "Git",
120
+ "checkpoints": "Git",
121
+ "plan": "Project",
122
+ "memory": "Project",
123
+ "todos": "Project",
124
+ "attach": "Project",
125
+ "status": "Runtime",
126
+ "context": "Runtime",
127
+ "health": "Runtime",
128
+ "tokens": "Runtime",
129
+ "trace": "Runtime",
130
+ "agents": "Work",
131
+ "work": "Work",
132
+ "timeline": "Work",
133
+ "queue": "Work",
134
+ "terminals": "Work",
135
+ "skills": "Extensions",
136
+ "mcp": "Extensions",
137
+ "exit": "General",
138
+ }
139
+
140
+
91
141
  _SECRET_CONFIG_KEYS = frozenset(
92
142
  {
93
143
  "access_key",
@@ -218,21 +268,19 @@ def all_command_suggestions(
218
268
  custom: dict[str, CustomCommand] | None = None,
219
269
  ) -> list[CommandSuggestion]:
220
270
  suggestions = [
221
- CommandSuggestion(command.invocation, command.description) for command in BUILTIN_COMMANDS
222
- ]
223
- suggestions.append(
224
- CommandSuggestion("/model --global MODEL", "Set the default model for every repository")
225
- )
226
- suggestions.append(
227
271
  CommandSuggestion(
228
- "/reasoning --global EFFORT",
229
- "Set the reasoning effort default for every repository",
272
+ command.invocation,
273
+ command.description,
274
+ _COMMAND_CATEGORIES.get(command.name, "Other"),
230
275
  )
231
- )
276
+ for command in BUILTIN_COMMANDS
277
+ ]
232
278
  if custom:
279
+ reserved = {command.name for command in BUILTIN_COMMANDS}
233
280
  suggestions.extend(
234
- CommandSuggestion(f"/{name} [ARGS]", command.description)
281
+ CommandSuggestion(f"/{name} [ARGS]", command.description, "Custom")
235
282
  for name, command in sorted(custom.items())
283
+ if name not in reserved
236
284
  )
237
285
  return suggestions
238
286
 
@@ -241,21 +289,27 @@ def config_command_suggestions(config: Any) -> list[CommandSuggestion]:
241
289
  suggestions = []
242
290
  for path, value in config_entries(config):
243
291
  display_value = value if len(value) <= 80 else f"{value[:77]}…"
244
- suggestions.append(CommandSuggestion(f"/config {path}", f"current: {display_value}"))
292
+ suggestions.append(
293
+ CommandSuggestion(f"/config {path}", f"current: {display_value}", "Settings")
294
+ )
245
295
  return suggestions
246
296
 
247
297
 
248
298
  def help_text(custom: dict[str, CustomCommand] | None = None) -> str:
249
299
  lines = ["Noah Code commands:", ""]
250
- for cmd in BUILTIN_COMMANDS:
251
- lines.append(f" {cmd.invocation:<28} {cmd.description}")
252
- lines.append(f" {'/model --global MODEL':<28} Set the default model for every repository")
253
- lines.append(f" {'/reasoning --global EFFORT':<28} Set the global reasoning effort default")
300
+ lines.extend(f" {cmd.invocation:<38} {cmd.description}" for cmd in BUILTIN_COMMANDS)
254
301
  if custom:
255
- lines.append("")
256
- lines.append("Custom commands:")
257
- for name, c in sorted(custom.items()):
258
- lines.append(f" /{name:<12} {c.description} ({c.source})")
302
+ reserved = {command.name for command in BUILTIN_COMMANDS}
303
+ visible_custom = [
304
+ (name, command)
305
+ for name, command in sorted(custom.items())
306
+ if name not in reserved
307
+ ]
308
+ else:
309
+ visible_custom = []
310
+ if visible_custom:
311
+ for name, c in visible_custom:
312
+ lines.append(f" /{name + ' [ARGS]':<37} {c.description}")
259
313
  lines.append("")
260
314
  lines.append(
261
315
  "File-journal undo only covers WorkspaceTools edits, not arbitrary shell mutations."
@@ -38,6 +38,7 @@ class ExpandedTurn:
38
38
 
39
39
  text: str
40
40
  images: list[Any] = field(default_factory=list)
41
+ paths: list[Path] = field(default_factory=list)
41
42
 
42
43
 
43
44
  def _workspace_files(root: Path) -> list[Path]:
@@ -100,6 +101,7 @@ def expand_turn(
100
101
  extras = [Path(path) for path in attach_paths or []]
101
102
  sections: list[str] = []
102
103
  images: list[Any] = []
104
+ paths: list[Path] = []
103
105
  seen: set[str] = set()
104
106
 
105
107
  for raw in [*mentioned, *[str(path) for path in extras]]:
@@ -110,6 +112,7 @@ def expand_turn(
110
112
  if key in seen:
111
113
  continue
112
114
  seen.add(key)
115
+ paths.append(resolved)
113
116
  relative = _display(root, resolved)
114
117
  suffix = resolved.suffix.lower()
115
118
  if suffix in IMAGE_TYPES:
@@ -128,7 +131,7 @@ def expand_turn(
128
131
  if not sections:
129
132
  return ExpandedTurn(text=text)
130
133
  notice = "Attached files from @mentions:\n\n" + "\n\n".join(sections)
131
- return ExpandedTurn(text=f"{text.rstrip()}\n\n{notice}", images=images)
134
+ return ExpandedTurn(text=f"{text.rstrip()}\n\n{notice}", images=images, paths=paths)
132
135
 
133
136
 
134
137
  def _resolve(root: Path, raw: str) -> Path | None:
@@ -134,6 +134,7 @@ class UIConfig(BaseModel):
134
134
  model_config = ConfigDict(extra="forbid")
135
135
 
136
136
  theme: ThemeName = "atom-one-dark"
137
+ animations: bool = True
137
138
  show_reasoning: bool = False
138
139
  markdown: bool = True
139
140
  frontend: Literal["tui", "console"] = "tui"
@@ -563,6 +564,62 @@ def save_user_theme(theme: str) -> Path:
563
564
  return path
564
565
 
565
566
 
567
+ def save_user_animations(enabled: bool) -> Path:
568
+ """Persist the reduced-motion preference inside the user ``[ui]`` table."""
569
+
570
+ path = _user_config_path()
571
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
572
+ existing = path.read_text() if path.is_file() else ""
573
+ lines = existing.splitlines(keepends=True)
574
+ replacement = f"animations = {'true' if enabled else 'false'}\n"
575
+
576
+ table_start = next(
577
+ (index for index, line in enumerate(lines) if line.strip() == "[ui]"),
578
+ None,
579
+ )
580
+ if table_start is None:
581
+ if lines and not lines[-1].endswith(("\n", "\r")):
582
+ lines[-1] += "\n"
583
+ if lines and lines[-1].strip():
584
+ lines.append("\n")
585
+ lines.extend(["[ui]\n", replacement])
586
+ else:
587
+ table_end = next(
588
+ (
589
+ index
590
+ for index, line in enumerate(lines[table_start + 1 :], start=table_start + 1)
591
+ if line.lstrip().startswith("[")
592
+ ),
593
+ len(lines),
594
+ )
595
+ animation_line = next(
596
+ (
597
+ index
598
+ for index, line in enumerate(
599
+ lines[table_start + 1 : table_end], start=table_start + 1
600
+ )
601
+ if re.match(r"^\s*animations\s*=", line)
602
+ ),
603
+ None,
604
+ )
605
+ if animation_line is None:
606
+ lines.insert(table_end, replacement)
607
+ else:
608
+ lines[animation_line] = replacement
609
+
610
+ descriptor, temporary_name = tempfile.mkstemp(prefix=".config-", dir=path.parent)
611
+ temporary_path = Path(temporary_name)
612
+ try:
613
+ with os.fdopen(descriptor, "w") as stream:
614
+ stream.write("".join(lines))
615
+ os.chmod(temporary_path, 0o600)
616
+ os.replace(temporary_path, path)
617
+ finally:
618
+ if temporary_path.exists():
619
+ temporary_path.unlink()
620
+ return path
621
+
622
+
566
623
  def _project_config_path(workspace: Path) -> Path:
567
624
  return workspace / ".noah-code" / "config.toml"
568
625
 
@@ -19,6 +19,7 @@ from noah_code.commands import config_text, help_text, parse_slash
19
19
  from noah_code.config import (
20
20
  REASONING_EFFORTS,
21
21
  NoahCodeConfig,
22
+ save_user_animations,
22
23
  save_user_default_model,
23
24
  save_user_reasoning_effort,
24
25
  save_user_theme,
@@ -248,6 +249,7 @@ class AgentHost:
248
249
  self._current_run_id: str | None = None
249
250
  self._interrupted_run: RunRecord | None = None
250
251
  self._pending_attach_paths: list[Path] = []
252
+ self._active_context_paths: set[Path] = set()
251
253
  self._pending_worktree_name = ""
252
254
  self.on_session_changed: Any = None # optional UI callback
253
255
 
@@ -956,6 +958,7 @@ class AgentHost:
956
958
  """Persist current session and open a fresh one in-process."""
957
959
  self._require_idle_turn()
958
960
  self._clear_steer_state()
961
+ self._active_context_paths.clear()
959
962
  await self._cancel_background_tasks()
960
963
  await self._persist_async()
961
964
  if workspace is not None:
@@ -984,6 +987,7 @@ class AgentHost:
984
987
  meta = self.store.load_meta(session_id)
985
988
  rebound = self.store.workspace_for_resume(meta, self.workspace)
986
989
  self._clear_steer_state()
990
+ self._active_context_paths.clear()
987
991
  await self._cancel_background_tasks()
988
992
  await self._persist_async()
989
993
  self._teardown_event_bridge()
@@ -1439,6 +1443,110 @@ class AgentHost:
1439
1443
  paths, self._pending_attach_paths = self._pending_attach_paths, []
1440
1444
  return paths
1441
1445
 
1446
+ def pending_attach_paths(self) -> tuple[Path, ...]:
1447
+ """Return files waiting to be attached to the next queued prompt."""
1448
+
1449
+ return tuple(self._pending_attach_paths)
1450
+
1451
+ def active_context_paths(self) -> tuple[Path, ...]:
1452
+ """Return attachment paths already delivered in the active session."""
1453
+
1454
+ return tuple(sorted(self._active_context_paths))
1455
+
1456
+ def context_snapshot(self) -> list[dict[str, str]]:
1457
+ """Return a display-safe inventory of context sources for the UI."""
1458
+
1459
+ from noah_code.project_notes import MEMORY_RELATIVE, PLAN_RELATIVE, MemoryStore, PlanStore
1460
+
1461
+ rows: list[dict[str, str]] = []
1462
+ root = self.workspace.root
1463
+ for relative in ("AGENTS.md", "CLAUDE.md", ".noah-code/instructions.md"):
1464
+ path = root / relative
1465
+ if path.is_file():
1466
+ rows.append(
1467
+ {
1468
+ "kind": "instruction",
1469
+ "label": relative,
1470
+ "detail": f"Repository instructions · {path}",
1471
+ }
1472
+ )
1473
+ if PlanStore(root).exists():
1474
+ rows.append(
1475
+ {
1476
+ "kind": "plan",
1477
+ "label": PLAN_RELATIVE,
1478
+ "detail": "Pinned project plan included at each turn boundary",
1479
+ }
1480
+ )
1481
+ if MemoryStore(root).exists():
1482
+ rows.append(
1483
+ {
1484
+ "kind": "memory",
1485
+ "label": MEMORY_RELATIVE,
1486
+ "detail": "Durable project conventions included in model context",
1487
+ }
1488
+ )
1489
+ for path in self.active_context_paths():
1490
+ rows.append(
1491
+ {
1492
+ "kind": "attachment",
1493
+ "label": path.name,
1494
+ "detail": f"Attached in this session · {path}",
1495
+ }
1496
+ )
1497
+ for path in self.pending_attach_paths():
1498
+ rows.append(
1499
+ {
1500
+ "kind": "pending",
1501
+ "label": path.name,
1502
+ "detail": f"Waiting for the next prompt · {path}",
1503
+ }
1504
+ )
1505
+ if self._agent is not None:
1506
+ with contextlib.suppress(Exception):
1507
+ for info in self.list_skill_infos():
1508
+ if info.active:
1509
+ rows.append(
1510
+ {
1511
+ "kind": "skill",
1512
+ "label": info.name,
1513
+ "detail": f"Active skill · {info.source}",
1514
+ }
1515
+ )
1516
+ for name in sorted(self._mcp_attached):
1517
+ rows.append(
1518
+ {
1519
+ "kind": "mcp",
1520
+ "label": name,
1521
+ "detail": "Connected MCP server tools available to Noah",
1522
+ }
1523
+ )
1524
+ return rows
1525
+
1526
+ def context_status_text(self) -> str:
1527
+ rows = self.context_snapshot()
1528
+ if not rows:
1529
+ return "Context sources\n Workspace defaults only"
1530
+ lines = ["Context sources"]
1531
+ lines.extend(f" {row['kind']:<11} {row['label']}\n {row['detail']}" for row in rows)
1532
+ return "\n".join(lines)
1533
+
1534
+ def remove_pending_attach(self, index: int) -> Path | None:
1535
+ if index < 0 or index >= len(self._pending_attach_paths):
1536
+ return None
1537
+ return self._pending_attach_paths.pop(index)
1538
+
1539
+ def remove_queued_steer(self, index: int) -> bool:
1540
+ item = self.steer_queue.remove(index)
1541
+ if item is None:
1542
+ return False
1543
+ if self._runtime is not None and item.sequence is not None:
1544
+ self._runtime.acknowledge_inbox(item.sequence, dropped=True)
1545
+ return True
1546
+
1547
+ def move_queued_steer(self, index: int, delta: int) -> bool:
1548
+ return self.steer_queue.move(index, delta)
1549
+
1442
1550
  def enqueue_steer(self, text: str, attach_paths: list[Path] | None = None) -> bool:
1443
1551
  """Queue a follow-up for the current turn. Returns True if oldest dropped."""
1444
1552
 
@@ -1591,6 +1699,26 @@ class AgentHost:
1591
1699
  )
1592
1700
  )
1593
1701
  return "handled"
1702
+ if name == "animations":
1703
+ requested = args.strip().lower()
1704
+ if not requested:
1705
+ state = "on" if self.config.ui.animations else "off"
1706
+ self.ui.render(HostEvent(HostEventKind.STATUS, f"animations={state}"))
1707
+ return "handled"
1708
+ if requested not in {"on", "off"}:
1709
+ self.ui.render(HostEvent(HostEventKind.ERROR, "usage: /animations [on|off]"))
1710
+ return "handled"
1711
+ enabled = requested == "on"
1712
+ path = save_user_animations(enabled)
1713
+ self.config.ui.animations = enabled
1714
+ self.ui.render(
1715
+ HostEvent(
1716
+ HostEventKind.STATUS,
1717
+ f"animations set to {requested} · saved in {path}",
1718
+ meta={"kind": "animations", "enabled": enabled},
1719
+ )
1720
+ )
1721
+ return "handled"
1594
1722
  if name in self._custom_commands:
1595
1723
  return await self._run_custom_command(name, args)
1596
1724
  if name == "exit" or name == "quit":
@@ -1803,6 +1931,18 @@ class AgentHost:
1803
1931
  if name == "work":
1804
1932
  self.ui.render(_command_output(self.work_status_text()))
1805
1933
  return "handled"
1934
+ if name == "queue":
1935
+ queued = self.steer_queue.items()
1936
+ attached = self.pending_attach_paths()
1937
+ lines = [f"Queued prompts: {len(queued)}", f"Pending attachments: {len(attached)}"]
1938
+ for index, item in enumerate(queued, start=1):
1939
+ preview = " ".join(item.text.split())[:80]
1940
+ suffix = f" · {len(item.attach_paths)} file(s)" if item.attach_paths else ""
1941
+ lines.append(f" {index}. {preview}{suffix}")
1942
+ for index, path in enumerate(attached, start=1):
1943
+ lines.append(f" A{index}. {path}")
1944
+ self.ui.render(_command_output("\n".join(lines)))
1945
+ return "handled"
1806
1946
  if name == "terminals":
1807
1947
  self.ui.render(_command_output(await agent.processes.terminal_status()))
1808
1948
  return "handled"
@@ -1842,6 +1982,17 @@ class AgentHost:
1842
1982
  )
1843
1983
  )
1844
1984
  return "handled"
1985
+ if name == "context":
1986
+ self.ui.render(_command_output(self.context_status_text()))
1987
+ return "handled"
1988
+ if name == "timeline":
1989
+ self.ui.render(
1990
+ HostEvent(
1991
+ HostEventKind.STATUS,
1992
+ "Open the task timeline with F2 in the TUI",
1993
+ )
1994
+ )
1995
+ return "handled"
1845
1996
  if name == "health":
1846
1997
  health = self._runtime.health() if self._runtime is not None else {}
1847
1998
  rows = ["Durable runtime health"]
@@ -1944,7 +2095,7 @@ class AgentHost:
1944
2095
  status = "history compacted" if compacted else "nothing eligible to compact"
1945
2096
  self.ui.render(HostEvent(HostEventKind.STATUS, status))
1946
2097
  return "handled"
1947
- if name in {"continue"}:
2098
+ if name == "continue":
1948
2099
  latest = self.store.latest_for_workspace(self.workspace)
1949
2100
  if latest is None:
1950
2101
  self.ui.render(
@@ -2101,6 +2252,7 @@ class AgentHost:
2101
2252
  return expand_turn(text, self.workspace.root, attach_paths=attach_paths)
2102
2253
 
2103
2254
  def _deliver_expanded(self, agent: Any, expanded: Any) -> None:
2255
+ self._active_context_paths.update(Path(path).resolve() for path in expanded.paths)
2104
2256
  if expanded.images:
2105
2257
  agent.media.queue(expanded.images)
2106
2258
  self.ui.render(
@@ -218,6 +218,13 @@ def list_providers(active_model: str = "") -> list[ProviderInfo]:
218
218
  ]
219
219
 
220
220
 
221
+ def model_setup_required(model: str) -> bool:
222
+ """Return whether a recognized model route is missing its credentials."""
223
+
224
+ active = [info for info in list_providers(model) if info.active]
225
+ return bool(active and not active[0].configured)
226
+
227
+
221
228
  def format_providers(active_model: str = "") -> str:
222
229
  lines = [
223
230
  "Model providers",