release: prepare v0.3.32 — finance tool, header redesign, expanded tests
Add Yahoo Finance quote tool with chart fallback, redesign header widget with proportional truncation and context bar, refactor footer status strip, expand test suite to 680+ tests, and fix blocking issues (usize underflow in header, tempdir leak in finance tests, per-call HTTP client creation).
This commit is contained in:
@@ -56,8 +56,6 @@ AI_HANDOFF.md
|
||||
result.json
|
||||
count_deps.py
|
||||
project_overhaul_prompt.md
|
||||
prompt_for_release_agent.md
|
||||
|
||||
.codex/
|
||||
docs/rlm-paper.txt
|
||||
.context/
|
||||
|
||||
@@ -6,43 +6,13 @@ This file provides context for AI assistants working on this project.
|
||||
|
||||
### Commands
|
||||
- Build: `cargo build`
|
||||
- Test: `cargo test`
|
||||
- Run: `cargo run`
|
||||
- Check: `cargo check`
|
||||
- Format: `cargo fmt`
|
||||
- Lint: `cargo clippy`
|
||||
|
||||
### Project: deepseek-tui
|
||||
- Test: `cargo test --workspace --all-features`
|
||||
- Lint: `cargo clippy --workspace --all-targets --all-features`
|
||||
- Format: `cargo fmt --all`
|
||||
- Run: `cargo run -p deepseek-tui`
|
||||
|
||||
### Documentation
|
||||
See README.md for project overview.
|
||||
|
||||
### Version Control
|
||||
This project uses Git. See .gitignore for excluded files.
|
||||
|
||||
|
||||
## Advanced Capabilities
|
||||
|
||||
### Model Context Protocol (MCP)
|
||||
This CLI supports MCP for extending tool access.
|
||||
- Use `mcp_read_resource` to read context from external servers.
|
||||
- Use `mcp_get_prompt` to leverage pre-defined expert prompts from servers.
|
||||
- You can connect to HTTP/SSE servers by adding their URL to `mcp.json`.
|
||||
|
||||
### Multi-Agent Orchestration
|
||||
For complex, multi-step tasks, you should delegate work:
|
||||
- **Sub-agents**: Use `agent_spawn` (or its alias `delegate_to_agent`) to launch a background assistant for a specific sub-task. Use `agent_result` to get their output.
|
||||
- **Swarms**: Use `agent_swarm` to orchestrate multiple sub-agents with dependencies. This is ideal for parallel exploration or complex refactoring where different parts of the project can be analyzed concurrently.
|
||||
|
||||
### Project Mapping
|
||||
- Use `project_map` to get a comprehensive view of the codebase structure. This tool respects `.gitignore` and provides a summary of key files.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Proactive Investigation**: Always start by exploring the codebase using `project_map` and `file_search`.
|
||||
- **Parallelism**: When you need to read multiple files or search across different areas, use parallel tool calls if possible.
|
||||
- **Delegation**: If a task is large, break it down into sub-tasks and use `agent_swarm` or `agent_spawn`.
|
||||
- **Testing**: Rigorously verify changes using `cargo test` and `cargo check`.
|
||||
See README.md for project overview, docs/ARCHITECTURE.md for internals.
|
||||
|
||||
## Trimtab Workflow
|
||||
|
||||
@@ -50,40 +20,20 @@ This repo uses the Trimtab closed-loop protocol for self-verifying agentic devel
|
||||
|
||||
- **Protocol:** `.trimtab/init-trimtab-protocol.md` (canonical — read this first)
|
||||
- **Task graph:** `DEPENDENCY_GRAPH.md` (crate deps + task deps with ready queue)
|
||||
- **Task queue:** `AI_HANDOFF.md` (7 open issues with priorities)
|
||||
- **Goals:** `todo.md` (high-level objectives)
|
||||
- **Task queue:** `AI_HANDOFF.md` (6 open items with priorities)
|
||||
- **Claude entrypoint:** `.claude/commands/init-trimtab.md`
|
||||
- **Codex skill:** `.codex/skills/init-trimtab/SKILL.md`
|
||||
|
||||
**No-self-verdict rule:** The agent that wrote code must not be the one to declare it passes. Always use an independent verifier (fresh Codex context or separate sub-agent).
|
||||
**No-self-verdict rule:** The agent that wrote code must not be the one to declare it passes. Always use an independent verifier (fresh context or separate sub-agent).
|
||||
|
||||
## DeepSeek-Specific Notes
|
||||
|
||||
- **Thinking Tokens**: DeepSeek models output thinking blocks (`ContentBlock::Thinking`) before final answers. The TUI streams and displays these with visual distinction.
|
||||
- **Reasoning Models**: `deepseek-reasoner` and `deepseek-r1` excel at step-by-step problem solving.
|
||||
- **Large Context Window**: 128k tokens. Use search tools to navigate efficiently.
|
||||
- **API**: OpenAI-compatible with Responses API preferred, chat completions as fallback. Base URL configurable for global (`api.deepseek.com`) or China (`api.deepseeki.com`).
|
||||
|
||||
## Important Notes
|
||||
|
||||
<!-- Add project-specific notes here -->
|
||||
|
||||
|
||||
- **Token/cost tracking inaccuracies**: Token counting and cost estimation may be inflated due to thinking token accounting bugs. Use `/compact` to manage context, and treat cost estimates as approximate.
|
||||
- **Web.run tool name**: Note that the tool is named `web.run` (single dot), not `web..run`. Some earlier versions of the CLI may have had this typo.
|
||||
|
||||
### DeepSeek-Specific Capabilities
|
||||
|
||||
This project is built specifically for DeepSeek models, leveraging their unique features:
|
||||
|
||||
**Thinking Tokens**: DeepSeek models can output thinking blocks (`ContentBlock::Thinking`) before providing final answers. The TUI supports streaming and displaying thinking tokens with visual distinction. You can use thinking tokens to reason step-by-step before committing to a response.
|
||||
|
||||
**Reasoning Models**: DeepSeek offers specialized reasoning models (e.g., `deepseek-reasoner`, `deepseek-r1`) that excel at step-by-step problem solving. Consider using these models for complex tasks.
|
||||
|
||||
**Large Context Window**: DeepSeek models have 128k context windows, allowing you to process large codebases. Use `project_map` and `file_search` to navigate efficiently.
|
||||
|
||||
**DeepSeek API**: The CLI uses DeepSeek's OpenAI‑compatible API with support for the Responses API endpoint. The base URL can be configured for global (`api.deepseek.com`) or China (`api.deepseeki.com`).
|
||||
|
||||
**Web Browsing**: For up‑to‑date information about DeepSeek models, documentation, or API changes, use `web.run` with citations. Example search: “DeepSeek API documentation”.
|
||||
|
||||
### Dogfooding Tips
|
||||
|
||||
As a DeepSeek model working on this project, you are “dogfooding” your own tool. Use this opportunity to:
|
||||
- Test the toolset thoroughly and report any issues.
|
||||
- Suggest improvements that would make DeepSeek models more effective.
|
||||
- Keep changes small, focused, and well‑tested.
|
||||
|
||||
Remember to run `cargo test` and `cargo check` after any changes.
|
||||
- **Modes**: Three modes — Plan (read-only investigation), Agent (tool use with approval), YOLO (auto-approved). See `docs/MODES.md` for details.
|
||||
|
||||
+24
-8
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.32] - 2026-04-11
|
||||
|
||||
### Added
|
||||
- Finance tool: Yahoo Finance v8 quote endpoint with chart fallback, supporting stocks, ETFs, indices, forex, and crypto lookups.
|
||||
- Header widget redesign: proportional truncation, context-usage bar with gradient fill, streaming indicator, and graceful narrow-terminal degradation.
|
||||
- Expanded test coverage: 680+ tests including footer state, context spans, plan prompt lifecycle, workspace context refresh, header rendering, and finance tool integration tests with wiremock.
|
||||
- Workspace context refresh with configurable TTL and deferred initial fetch.
|
||||
- Config command additions for runtime settings management.
|
||||
|
||||
### Changed
|
||||
- Redesigned footer status strip with mode/model/status layout, context bar, and narrow-terminal fallback.
|
||||
- Plan prompt now uses numeric selection (1-4) instead of keyword input; old aliases are sent as regular messages.
|
||||
- Archived outdated docs (`workspace_migration_status.md` -> `docs/archive/`).
|
||||
- Trimmed AGENTS.md boilerplate and updated task counts.
|
||||
- Clarified release-surface documentation: crates.io publication may lag the workspace/npm wrapper.
|
||||
|
||||
### Fixed
|
||||
- Header `metadata_spans` now uses `saturating_sub` to prevent underflow on narrow terminals.
|
||||
- Finance tool reuses a single HTTP client instead of rebuilding per request.
|
||||
- Finance tool tests no longer leak temp directories.
|
||||
|
||||
## [0.3.31] - 2026-03-08
|
||||
|
||||
### Added
|
||||
@@ -409,10 +430,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Hooks system and config profiles
|
||||
- Example skills and launch assets
|
||||
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...HEAD
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.30...v0.3.31
|
||||
[0.3.30]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.29...v0.3.30
|
||||
[0.3.29]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.29
|
||||
[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.32...HEAD
|
||||
[0.3.32]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.31...v0.3.32
|
||||
[0.3.31]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.28...v0.3.31
|
||||
[0.3.28]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.27...v0.3.28
|
||||
[0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23
|
||||
[0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22
|
||||
@@ -424,9 +444,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.3.12]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.11...v0.3.12
|
||||
[0.3.11]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.10...v0.3.11
|
||||
[0.3.10]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.9]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.8]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.7]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.6...v0.3.10
|
||||
[0.3.6]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.3...v0.3.4
|
||||
@@ -435,7 +452,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.3.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.2...v0.3.0
|
||||
[0.2.2]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.1]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.2.0...v0.2.2
|
||||
[0.2.0]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.2.0
|
||||
[0.0.2]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.2
|
||||
[0.0.1]: https://github.com/Hmbown/DeepSeek-TUI/releases/tag/v0.0.1
|
||||
|
||||
Generated
+13
-13
@@ -806,7 +806,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-agent"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"deepseek-config",
|
||||
"serde",
|
||||
@@ -814,7 +814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-app-server"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -837,7 +837,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-config"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dirs",
|
||||
@@ -848,7 +848,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -867,7 +867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-execpolicy"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -876,7 +876,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-hooks"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -890,7 +890,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-mcp"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-protocol",
|
||||
@@ -900,7 +900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-protocol"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -908,7 +908,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -920,7 +920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tools"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -933,7 +933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arboard",
|
||||
@@ -987,7 +987,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-cli"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -1005,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-tui-core"
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ default-members = ["crates/cli", "crates/app-server", "crates/tui"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.3.31"
|
||||
version = "0.3.32"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/Hmbown/DeepSeek-TUI"
|
||||
|
||||
+19
-22
@@ -67,40 +67,37 @@ Layer 5: deepseek-tui-cli
|
||||
Canonical source: https://linear.app/shannon-labs/project/deepseek-tui-6213bbbeaa26
|
||||
|
||||
```
|
||||
[High] SHA-2794 UI Footer Redesign (Kimi CLI Style)
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/tui/ui.rs, crates/tui/src/palette.rs
|
||||
[High] SHA-2794 UI Footer Redesign (Kimi CLI Style) ← DONE (v0.3.31)
|
||||
-> landed: mode/model/token/cost layout, quadrant separators, context bar
|
||||
-> remaining polish tracked in AI_HANDOFF.md
|
||||
|
||||
[High] SHA-2795 Thinking vs Normal Chat Delineation
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/tui/ui.rs, history.rs, streaming.rs
|
||||
[High] SHA-2795 Thinking vs Normal Chat Delineation ← DONE (v0.3.31)
|
||||
-> landed: labeled delimiters, separate transcript cell, show_thinking
|
||||
|
||||
[High] SHA-2798 Finance Tool Replacement
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/tools/
|
||||
[High] SHA-2798 Finance Tool Replacement ← DONE (v0.3.31)
|
||||
-> landed: Yahoo Finance v8 + CoinGecko fallback
|
||||
|
||||
[Med] SHA-2796 Intelligent Compaction UX
|
||||
-> no blockers ← READY
|
||||
-> files: crates/tui/src/compaction.rs, core/engine.rs
|
||||
[Med] SHA-2796 Intelligent Compaction UX ← DONE (v0.3.31)
|
||||
-> landed: auto-compaction, /compact, status strip, CompactionCompleted stats
|
||||
|
||||
[Med] SHA-2797 Escape Key After Plan Mode
|
||||
-> no blockers (investigation) ← READY
|
||||
-> base fix landed (v0.3.31); remaining: regression test coverage ← READY
|
||||
-> files: crates/tui/src/tui/ui.rs, app.rs
|
||||
|
||||
[Med] SHA-2799 "Alive and Animated" Feel
|
||||
-> blocked by SHA-2794, SHA-2795
|
||||
-> was blocked by SHA-2794, SHA-2795 (now done) ← READY
|
||||
-> files: crates/tui/src/tui/ (various)
|
||||
|
||||
[Med] SHA-2801 Docs and Workflow Update
|
||||
-> blocked by SHA-2798
|
||||
-> was blocked by SHA-2798 (now done) ← READY
|
||||
-> files: AGENTS.md, README.md, CHANGELOG.md
|
||||
|
||||
[Med] SHA-2802 Release Prep
|
||||
-> blocked by SHA-2794, SHA-2795, SHA-2798
|
||||
-> was blocked by SHA-2794, SHA-2795, SHA-2798 (all done) ← READY
|
||||
-> files: Cargo.toml, CHANGELOG.md, npm/
|
||||
|
||||
[Low] SHA-2800 Header Redesign
|
||||
-> blocked by SHA-2794
|
||||
-> was blocked by SHA-2794 (now done) ← READY
|
||||
-> files: crates/tui/src/tui/widgets/header.rs
|
||||
|
||||
[Low] SHA-2803 Context Window Visualization
|
||||
@@ -110,8 +107,8 @@ Canonical source: https://linear.app/shannon-labs/project/deepseek-tui-6213bbbea
|
||||
|
||||
## Ready Queue (unblocked, by priority)
|
||||
|
||||
1. **SHA-2794** UI Footer Redesign (High)
|
||||
2. **SHA-2795** Thinking vs Chat Delineation (High)
|
||||
3. **SHA-2798** Finance Tool Replacement (High)
|
||||
4. **SHA-2796** Intelligent Compaction UX (Medium)
|
||||
5. **SHA-2797** Escape Key After Plan Mode (Medium)
|
||||
1. **SHA-2797** Escape Key regression test (Medium)
|
||||
2. **SHA-2799** "Alive and Animated" Feel (Medium)
|
||||
3. **SHA-2801** Docs and Workflow Update (Medium)
|
||||
4. **SHA-2802** Release Prep (Medium)
|
||||
5. **SHA-2800** Header Redesign (Low)
|
||||
|
||||
@@ -49,7 +49,9 @@ cargo install --path crates/tui --locked
|
||||
|
||||
The canonical crates.io packages for this repository are `deepseek-tui` and
|
||||
`deepseek-tui-cli`. The unrelated `deepseek-cli` crate is not part of this
|
||||
project.
|
||||
project. crates.io publication can lag the repository workspace version and the
|
||||
npm wrapper, so use npm or install from source if you need the newest release
|
||||
surface immediately.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
|
||||
crate::tui::app::ComposerDensity::from_setting(&settings.composer_density);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"composer_border" | "border" => {
|
||||
app.composer_border = settings.composer_border;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
app.transcript_spacing =
|
||||
crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
@@ -490,6 +494,19 @@ mod tests {
|
||||
assert!(msg.contains("(session only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_composer_border_updates_live_app() {
|
||||
let _lock = lock_test_env();
|
||||
let mut app = create_test_app();
|
||||
app.composer_border = true;
|
||||
|
||||
let result = set_config(&mut app, Some("composer_border false"));
|
||||
|
||||
assert!(result.message.is_some());
|
||||
assert!(!app.composer_border);
|
||||
assert!(app.needs_redraw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trust_enables_flag() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -1629,7 +1629,11 @@ impl RuntimeThreadManager {
|
||||
.await?;
|
||||
}
|
||||
EngineEvent::CompactionCompleted {
|
||||
id, auto, message, ..
|
||||
id,
|
||||
auto,
|
||||
message,
|
||||
messages_before,
|
||||
messages_after,
|
||||
} => {
|
||||
if let Some(item_id) = compaction_items.remove(&id) {
|
||||
let mut item = self.store.load_item(&item_id)?;
|
||||
@@ -1643,7 +1647,12 @@ impl RuntimeThreadManager {
|
||||
Some(&turn_id),
|
||||
Some(&item_id),
|
||||
"item.completed",
|
||||
json!({ "item": item, "auto": auto }),
|
||||
json!({
|
||||
"item": item,
|
||||
"auto": auto,
|
||||
"messages_before": messages_before,
|
||||
"messages_after": messages_after,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -3336,7 +3345,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compaction_lifecycle_emits_item_events_for_auto_and_manual() -> Result<()> {
|
||||
async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
@@ -3377,8 +3386,8 @@ mod tests {
|
||||
id: "auto_compact_1".to_string(),
|
||||
auto: true,
|
||||
message: "auto compact done".to_string(),
|
||||
messages_before: None,
|
||||
messages_after: None,
|
||||
messages_before: Some(7),
|
||||
messages_after: Some(3),
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
@@ -3407,8 +3416,8 @@ mod tests {
|
||||
id: "manual_compact_1".to_string(),
|
||||
auto: false,
|
||||
message: "manual compact done".to_string(),
|
||||
messages_before: None,
|
||||
messages_after: None,
|
||||
messages_before: Some(5),
|
||||
messages_after: Some(2),
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
@@ -3472,6 +3481,18 @@ mod tests {
|
||||
== Some("context_compaction")
|
||||
&& ev.payload.get("auto").and_then(Value::as_bool) == Some(true)
|
||||
}));
|
||||
assert!(events.iter().any(|ev| {
|
||||
ev.event == "item.completed"
|
||||
&& ev
|
||||
.payload
|
||||
.get("item")
|
||||
.and_then(|item| item.get("kind"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("context_compaction")
|
||||
&& ev.payload.get("auto").and_then(Value::as_bool) == Some(true)
|
||||
&& ev.payload.get("messages_before").and_then(Value::as_u64) == Some(7)
|
||||
&& ev.payload.get("messages_after").and_then(Value::as_u64) == Some(3)
|
||||
}));
|
||||
assert!(events.iter().any(|ev| {
|
||||
ev.event == "item.completed"
|
||||
&& ev
|
||||
@@ -3481,6 +3502,8 @@ mod tests {
|
||||
.and_then(Value::as_str)
|
||||
== Some("context_compaction")
|
||||
&& ev.payload.get("auto").and_then(Value::as_bool) == Some(false)
|
||||
&& ev.payload.get("messages_before").and_then(Value::as_u64) == Some(5)
|
||||
&& ev.payload.get("messages_after").and_then(Value::as_u64) == Some(2)
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct Settings {
|
||||
pub show_tool_details: bool,
|
||||
/// Composer layout density: compact, comfortable, spacious
|
||||
pub composer_density: String,
|
||||
/// Show a border around the composer input area
|
||||
pub composer_border: bool,
|
||||
/// Transcript spacing rhythm: compact, comfortable, spacious
|
||||
pub transcript_spacing: String,
|
||||
/// Default mode: "agent", "plan", "yolo"
|
||||
@@ -51,6 +53,7 @@ impl Default for Settings {
|
||||
show_thinking: true,
|
||||
show_tool_details: true,
|
||||
composer_density: "comfortable".to_string(),
|
||||
composer_border: true,
|
||||
transcript_spacing: "comfortable".to_string(),
|
||||
default_mode: "agent".to_string(),
|
||||
sidebar_width_percent: 28,
|
||||
@@ -159,6 +162,9 @@ impl Settings {
|
||||
}
|
||||
self.composer_density = normalized.to_string();
|
||||
}
|
||||
"composer_border" | "border" => {
|
||||
self.composer_border = parse_bool(value)?;
|
||||
}
|
||||
"transcript_spacing" | "spacing" => {
|
||||
let normalized = normalize_transcript_spacing(value);
|
||||
if !["compact", "comfortable", "spacious"].contains(&normalized) {
|
||||
@@ -253,6 +259,7 @@ impl Settings {
|
||||
lines.push(format!(" show_thinking: {}", self.show_thinking));
|
||||
lines.push(format!(" show_tool_details: {}", self.show_tool_details));
|
||||
lines.push(format!(" composer_density: {}", self.composer_density));
|
||||
lines.push(format!(" composer_border: {}", self.composer_border));
|
||||
lines.push(format!(" transcript_spacing: {}", self.transcript_spacing));
|
||||
lines.push(format!(" default_mode: {}", self.default_mode));
|
||||
lines.push(format!(
|
||||
@@ -287,6 +294,10 @@ impl Settings {
|
||||
"composer_density",
|
||||
"Composer density: compact, comfortable, spacious",
|
||||
),
|
||||
(
|
||||
"composer_border",
|
||||
"Show a border around the composer input area: on/off",
|
||||
),
|
||||
(
|
||||
"transcript_spacing",
|
||||
"Transcript spacing: compact, comfortable, spacious",
|
||||
|
||||
@@ -0,0 +1,951 @@
|
||||
//! Finance quote tool backed by Yahoo Finance-style public endpoints.
|
||||
//!
|
||||
//! The tool prefers Yahoo's quote endpoint and falls back to the chart endpoint
|
||||
//! when quote access is unavailable or returns no data.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
optional_str, optional_u64,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
|
||||
const MAX_TIMEOUT_MS: u64 = 60_000;
|
||||
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
|
||||
const QUOTE_SOURCE: &str = "yahoo_quote";
|
||||
const CHART_SOURCE: &str = "yahoo_chart";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FinanceEndpoints {
|
||||
quote_base: String,
|
||||
chart_base: String,
|
||||
}
|
||||
|
||||
impl Default for FinanceEndpoints {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
quote_base: std::env::var("DEEPSEEK_FINANCE_QUOTE_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://query1.finance.yahoo.com/v7/finance/quote".into()),
|
||||
chart_base: std::env::var("DEEPSEEK_FINANCE_CHART_BASE_URL")
|
||||
.unwrap_or_else(|_| "https://query1.finance.yahoo.com/v8/finance/chart".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FinanceEndpoints {
|
||||
fn quote_url(&self, symbol: &str) -> String {
|
||||
format!(
|
||||
"{}?symbols={}",
|
||||
self.quote_base.trim_end_matches('/'),
|
||||
crate::utils::url_encode(symbol)
|
||||
)
|
||||
}
|
||||
|
||||
fn chart_url(&self, symbol: &str) -> String {
|
||||
format!(
|
||||
"{}/{}?interval=1d&range=5d",
|
||||
self.chart_base.trim_end_matches('/'),
|
||||
crate::utils::url_encode(symbol)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FinanceRequest {
|
||||
requested_ticker: String,
|
||||
resolved_symbol: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct FinanceQuoteResponse {
|
||||
requested_ticker: String,
|
||||
ticker: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
price: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
currency: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
change: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
change_percent: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
previous_close: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
market_state: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
quote_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
exchange: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
market_time: Option<i64>,
|
||||
source: String,
|
||||
fallback_used: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum AttemptFailureKind {
|
||||
Timeout,
|
||||
NotFound,
|
||||
Upstream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AttemptFailure {
|
||||
endpoint: &'static str,
|
||||
kind: AttemptFailureKind,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
impl AttemptFailure {
|
||||
fn timeout(endpoint: &'static str) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
kind: AttemptFailureKind::Timeout,
|
||||
detail: "request timed out".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn not_found(endpoint: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
kind: AttemptFailureKind::NotFound,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upstream(endpoint: &'static str, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoint,
|
||||
kind: AttemptFailureKind::Upstream,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_timeout(&self) -> bool {
|
||||
matches!(self.kind, AttemptFailureKind::Timeout)
|
||||
}
|
||||
|
||||
fn is_not_found(&self) -> bool {
|
||||
matches!(self.kind, AttemptFailureKind::NotFound)
|
||||
}
|
||||
|
||||
fn summary(&self) -> String {
|
||||
format!("{}: {}", self.endpoint, self.detail)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FinanceTool {
|
||||
endpoints: FinanceEndpoints,
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl FinanceTool {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
endpoints: FinanceEndpoints::default(),
|
||||
client: Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("failed to build HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_endpoints(quote_base: impl Into<String>, chart_base: impl Into<String>) -> Self {
|
||||
Self {
|
||||
endpoints: FinanceEndpoints {
|
||||
quote_base: quote_base.into(),
|
||||
chart_base: chart_base.into(),
|
||||
},
|
||||
client: Client::builder()
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.expect("failed to build HTTP client"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FinanceTool {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for FinanceTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"finance"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Fetch a live market quote for a stock, ETF, or crypto ticker using Yahoo Finance-style public endpoints."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "Ticker symbol to look up (for example: AAPL, SPY, BTC)."
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "Alias for ticker."
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "Optional asset type hint such as equity, fund, crypto, or index."
|
||||
},
|
||||
"market": {
|
||||
"type": "string",
|
||||
"description": "Optional market hint retained for compatibility with finance-style tool calls."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "integer",
|
||||
"description": "Request timeout in milliseconds (default: 10000, max: 60000)."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["ticker"] },
|
||||
{ "required": ["symbol"] }
|
||||
],
|
||||
"additionalProperties": false
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![
|
||||
ToolCapability::ReadOnly,
|
||||
ToolCapability::Network,
|
||||
ToolCapability::Sandboxable,
|
||||
]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
fn supports_parallel(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let raw_ticker = optional_str(&input, "ticker")
|
||||
.or_else(|| optional_str(&input, "symbol"))
|
||||
.ok_or_else(|| ToolError::missing_field("ticker"))?
|
||||
.trim();
|
||||
if raw_ticker.is_empty() {
|
||||
return Err(ToolError::invalid_input("ticker cannot be empty"));
|
||||
}
|
||||
|
||||
let type_hint = optional_str(&input, "type").map(str::trim);
|
||||
let _market_hint = optional_str(&input, "market").map(str::trim);
|
||||
let timeout_ms =
|
||||
optional_u64(&input, "timeout_ms", DEFAULT_TIMEOUT_MS).clamp(100, MAX_TIMEOUT_MS);
|
||||
|
||||
let request = normalize_request(raw_ticker, type_hint);
|
||||
let timeout = Duration::from_millis(timeout_ms);
|
||||
|
||||
let quote_result =
|
||||
fetch_quote_endpoint(&self.client, timeout, &self.endpoints, &request).await;
|
||||
match quote_result {
|
||||
Ok(result) => {
|
||||
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
|
||||
}
|
||||
Err(first_failure) => {
|
||||
match fetch_chart_endpoint(&self.client, timeout, &self.endpoints, &request).await {
|
||||
Ok(result) => ToolResult::json(&result)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string())),
|
||||
Err(second_failure) => Err(finalize_failure(
|
||||
&request,
|
||||
timeout_ms,
|
||||
&[first_failure, second_failure],
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_request(raw_ticker: &str, type_hint: Option<&str>) -> FinanceRequest {
|
||||
let requested_ticker = raw_ticker.trim().to_ascii_uppercase();
|
||||
let resolved_symbol = if requested_ticker == "BTC" {
|
||||
"BTC-USD".to_string()
|
||||
} else if type_hint.is_some_and(|hint| hint.eq_ignore_ascii_case("crypto"))
|
||||
&& !requested_ticker.contains('-')
|
||||
{
|
||||
format!("{requested_ticker}-USD")
|
||||
} else {
|
||||
requested_ticker.clone()
|
||||
};
|
||||
|
||||
FinanceRequest {
|
||||
requested_ticker,
|
||||
resolved_symbol,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_quote_endpoint(
|
||||
client: &Client,
|
||||
timeout: Duration,
|
||||
endpoints: &FinanceEndpoints,
|
||||
request: &FinanceRequest,
|
||||
) -> Result<FinanceQuoteResponse, AttemptFailure> {
|
||||
let url = endpoints.quote_url(&request.resolved_symbol);
|
||||
let body = fetch_response_body(client, timeout, &url, QUOTE_SOURCE).await?;
|
||||
let parsed: QuoteEndpointResponse = serde_json::from_str(&body).map_err(|e| {
|
||||
AttemptFailure::upstream(QUOTE_SOURCE, format!("invalid JSON response: {e}"))
|
||||
})?;
|
||||
|
||||
let quote = parsed
|
||||
.quote_response
|
||||
.result
|
||||
.into_iter()
|
||||
.find(|item| item.symbol.eq_ignore_ascii_case(&request.resolved_symbol))
|
||||
.ok_or_else(|| {
|
||||
AttemptFailure::not_found(
|
||||
QUOTE_SOURCE,
|
||||
format!("no result for symbol '{}'", request.resolved_symbol),
|
||||
)
|
||||
})?;
|
||||
|
||||
let price = quote.regular_market_price.ok_or_else(|| {
|
||||
AttemptFailure::upstream(QUOTE_SOURCE, "response missing regularMarketPrice")
|
||||
})?;
|
||||
let previous_close = quote.regular_market_previous_close;
|
||||
let change = quote
|
||||
.regular_market_change
|
||||
.or_else(|| compute_change(price, previous_close));
|
||||
let change_percent = quote
|
||||
.regular_market_change_percent
|
||||
.or_else(|| compute_change_percent(price, previous_close));
|
||||
|
||||
Ok(FinanceQuoteResponse {
|
||||
requested_ticker: request.requested_ticker.clone(),
|
||||
ticker: quote.symbol,
|
||||
name: quote.long_name.or(quote.short_name),
|
||||
price,
|
||||
currency: quote.currency,
|
||||
change,
|
||||
change_percent,
|
||||
previous_close,
|
||||
market_state: quote.market_state,
|
||||
quote_type: quote.quote_type,
|
||||
exchange: quote.full_exchange_name.or(quote.exchange),
|
||||
market_time: quote.regular_market_time,
|
||||
source: QUOTE_SOURCE.to_string(),
|
||||
fallback_used: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_chart_endpoint(
|
||||
client: &Client,
|
||||
timeout: Duration,
|
||||
endpoints: &FinanceEndpoints,
|
||||
request: &FinanceRequest,
|
||||
) -> Result<FinanceQuoteResponse, AttemptFailure> {
|
||||
let url = endpoints.chart_url(&request.resolved_symbol);
|
||||
let body = fetch_response_body(client, timeout, &url, CHART_SOURCE).await?;
|
||||
let parsed: ChartEndpointResponse = serde_json::from_str(&body).map_err(|e| {
|
||||
AttemptFailure::upstream(CHART_SOURCE, format!("invalid JSON response: {e}"))
|
||||
})?;
|
||||
|
||||
if let Some(error) = parsed.chart.error {
|
||||
let description = error
|
||||
.description
|
||||
.unwrap_or_else(|| "chart endpoint returned an error".to_string());
|
||||
if error
|
||||
.code
|
||||
.as_deref()
|
||||
.is_some_and(|code| code.eq_ignore_ascii_case("Not Found"))
|
||||
|| description.to_ascii_lowercase().contains("not found")
|
||||
|| description
|
||||
.to_ascii_lowercase()
|
||||
.contains("symbol may be delisted")
|
||||
{
|
||||
return Err(AttemptFailure::not_found(CHART_SOURCE, description));
|
||||
}
|
||||
return Err(AttemptFailure::upstream(CHART_SOURCE, description));
|
||||
}
|
||||
|
||||
let result = parsed
|
||||
.chart
|
||||
.result
|
||||
.and_then(|mut entries| entries.drain(..).next())
|
||||
.ok_or_else(|| {
|
||||
AttemptFailure::not_found(
|
||||
CHART_SOURCE,
|
||||
format!("no chart data for symbol '{}'", request.resolved_symbol),
|
||||
)
|
||||
})?;
|
||||
|
||||
let meta = result.meta;
|
||||
let price = meta.regular_market_price.ok_or_else(|| {
|
||||
AttemptFailure::upstream(CHART_SOURCE, "response missing regularMarketPrice")
|
||||
})?;
|
||||
let previous_close = meta.chart_previous_close.or(meta.previous_close);
|
||||
let change = compute_change(price, previous_close);
|
||||
let change_percent = compute_change_percent(price, previous_close);
|
||||
|
||||
Ok(FinanceQuoteResponse {
|
||||
requested_ticker: request.requested_ticker.clone(),
|
||||
ticker: meta.symbol,
|
||||
name: meta.long_name.or(meta.short_name),
|
||||
price,
|
||||
currency: meta.currency,
|
||||
change,
|
||||
change_percent,
|
||||
previous_close,
|
||||
market_state: None,
|
||||
quote_type: meta.instrument_type,
|
||||
exchange: meta.full_exchange_name.or(meta.exchange_name),
|
||||
market_time: meta.regular_market_time,
|
||||
source: CHART_SOURCE.to_string(),
|
||||
fallback_used: true,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_response_body(
|
||||
client: &Client,
|
||||
timeout: Duration,
|
||||
url: &str,
|
||||
endpoint: &'static str,
|
||||
) -> Result<String, AttemptFailure> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.timeout(timeout)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
if err.is_timeout() {
|
||||
AttemptFailure::timeout(endpoint)
|
||||
} else {
|
||||
AttemptFailure::upstream(endpoint, format!("request failed: {err}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.map_err(|err| {
|
||||
if err.is_timeout() {
|
||||
AttemptFailure::timeout(endpoint)
|
||||
} else {
|
||||
AttemptFailure::upstream(endpoint, format!("failed to read response body: {err}"))
|
||||
}
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(status_failure(endpoint, status, &body));
|
||||
}
|
||||
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
fn status_failure(endpoint: &'static str, status: StatusCode, body: &str) -> AttemptFailure {
|
||||
if endpoint == CHART_SOURCE && status == StatusCode::NOT_FOUND {
|
||||
return AttemptFailure::not_found(endpoint, format!("HTTP {}", status.as_u16()));
|
||||
}
|
||||
|
||||
let snippet = body.trim();
|
||||
let detail = if snippet.is_empty() {
|
||||
format!("HTTP {}", status.as_u16())
|
||||
} else {
|
||||
format!("HTTP {} ({})", status.as_u16(), truncate_for_error(snippet))
|
||||
};
|
||||
|
||||
AttemptFailure::upstream(endpoint, detail)
|
||||
}
|
||||
|
||||
fn finalize_failure(
|
||||
request: &FinanceRequest,
|
||||
timeout_ms: u64,
|
||||
failures: &[AttemptFailure],
|
||||
) -> ToolError {
|
||||
if failures.iter().all(AttemptFailure::is_not_found) {
|
||||
return ToolError::invalid_input(format!(
|
||||
"Unknown finance ticker '{}'",
|
||||
request.requested_ticker
|
||||
));
|
||||
}
|
||||
|
||||
if failures.iter().any(AttemptFailure::is_timeout) {
|
||||
return ToolError::Timeout {
|
||||
seconds: millis_to_timeout_seconds(timeout_ms),
|
||||
};
|
||||
}
|
||||
|
||||
let detail = failures
|
||||
.iter()
|
||||
.map(AttemptFailure::summary)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
ToolError::execution_failed(format!(
|
||||
"Finance lookup failed for '{}': {}",
|
||||
request.requested_ticker, detail
|
||||
))
|
||||
}
|
||||
|
||||
fn compute_change(price: f64, previous_close: Option<f64>) -> Option<f64> {
|
||||
previous_close.map(|prev| price - prev)
|
||||
}
|
||||
|
||||
fn compute_change_percent(price: f64, previous_close: Option<f64>) -> Option<f64> {
|
||||
previous_close.and_then(|prev| {
|
||||
if prev.abs() < f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some(((price - prev) / prev) * 100.0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn millis_to_timeout_seconds(timeout_ms: u64) -> u64 {
|
||||
timeout_ms.saturating_add(999) / 1000
|
||||
}
|
||||
|
||||
fn truncate_for_error(text: &str) -> String {
|
||||
const MAX_ERROR_CHARS: usize = 120;
|
||||
let mut out = String::new();
|
||||
for ch in text.chars().take(MAX_ERROR_CHARS) {
|
||||
out.push(ch);
|
||||
}
|
||||
if text.chars().count() > MAX_ERROR_CHARS {
|
||||
out.push_str("...");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QuoteEndpointResponse {
|
||||
quote_response: QuoteResponseBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QuoteResponseBody {
|
||||
result: Vec<QuoteItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct QuoteItem {
|
||||
symbol: String,
|
||||
#[serde(default)]
|
||||
short_name: Option<String>,
|
||||
#[serde(default)]
|
||||
long_name: Option<String>,
|
||||
#[serde(default)]
|
||||
regular_market_price: Option<f64>,
|
||||
#[serde(default)]
|
||||
regular_market_change: Option<f64>,
|
||||
#[serde(default)]
|
||||
regular_market_change_percent: Option<f64>,
|
||||
#[serde(default)]
|
||||
regular_market_previous_close: Option<f64>,
|
||||
#[serde(default)]
|
||||
regular_market_time: Option<i64>,
|
||||
#[serde(default)]
|
||||
market_state: Option<String>,
|
||||
#[serde(default)]
|
||||
quote_type: Option<String>,
|
||||
#[serde(default)]
|
||||
currency: Option<String>,
|
||||
#[serde(default)]
|
||||
exchange: Option<String>,
|
||||
#[serde(default)]
|
||||
full_exchange_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartEndpointResponse {
|
||||
chart: ChartBody,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartBody {
|
||||
#[serde(default)]
|
||||
result: Option<Vec<ChartResult>>,
|
||||
#[serde(default)]
|
||||
error: Option<ChartErrorBody>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartResult {
|
||||
meta: ChartMeta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ChartMeta {
|
||||
symbol: String,
|
||||
#[serde(default)]
|
||||
short_name: Option<String>,
|
||||
#[serde(default)]
|
||||
long_name: Option<String>,
|
||||
#[serde(default)]
|
||||
currency: Option<String>,
|
||||
#[serde(default)]
|
||||
regular_market_price: Option<f64>,
|
||||
#[serde(default)]
|
||||
regular_market_time: Option<i64>,
|
||||
#[serde(default)]
|
||||
chart_previous_close: Option<f64>,
|
||||
#[serde(default)]
|
||||
previous_close: Option<f64>,
|
||||
#[serde(default)]
|
||||
instrument_type: Option<String>,
|
||||
#[serde(default)]
|
||||
exchange_name: Option<String>,
|
||||
#[serde(default)]
|
||||
full_exchange_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChartErrorBody {
|
||||
#[serde(default)]
|
||||
code: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn tool_with_server(server: &MockServer) -> FinanceTool {
|
||||
FinanceTool::with_endpoints(
|
||||
server.uri().to_string() + "/quote",
|
||||
server.uri().to_string() + "/chart",
|
||||
)
|
||||
}
|
||||
|
||||
fn context() -> (ToolContext, tempfile::TempDir) {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let path = tmp.path().to_path_buf();
|
||||
let ctx = ToolContext::new(path);
|
||||
(ctx, tmp)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_uses_quote_endpoint_when_available() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "AAPL"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"quoteResponse": {
|
||||
"result": [{
|
||||
"symbol": "AAPL",
|
||||
"shortName": "Apple Inc.",
|
||||
"regularMarketPrice": 189.23,
|
||||
"regularMarketChange": 1.12,
|
||||
"regularMarketChangePercent": 0.595,
|
||||
"regularMarketPreviousClose": 188.11,
|
||||
"regularMarketTime": 1_710_000_000,
|
||||
"marketState": "REGULAR",
|
||||
"quoteType": "EQUITY",
|
||||
"currency": "USD",
|
||||
"fullExchangeName": "NasdaqGS"
|
||||
}]
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let result = tool
|
||||
.execute(json!({"ticker": "aapl"}), &context().0)
|
||||
.await
|
||||
.expect("finance quote should succeed");
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&result.content).expect("tool output should be json");
|
||||
assert_eq!(parsed["requested_ticker"], "AAPL");
|
||||
assert_eq!(parsed["ticker"], "AAPL");
|
||||
assert_eq!(parsed["source"], QUOTE_SOURCE);
|
||||
assert_eq!(parsed["fallback_used"], false);
|
||||
assert_eq!(parsed["price"], 189.23);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_falls_back_to_chart_for_btc() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "BTC-USD"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/BTC-USD"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "BTC-USD",
|
||||
"longName": "Bitcoin USD",
|
||||
"currency": "USD",
|
||||
"regularMarketPrice": 73474.88,
|
||||
"regularMarketTime": 1_710_000_001,
|
||||
"chartPreviousClose": 72974.19,
|
||||
"instrumentType": "CRYPTOCURRENCY",
|
||||
"fullExchangeName": "CCC"
|
||||
}
|
||||
}],
|
||||
"error": null
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let result = tool
|
||||
.execute(json!({"ticker": "BTC", "type": "crypto"}), &context().0)
|
||||
.await
|
||||
.expect("finance chart fallback should succeed");
|
||||
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&result.content).expect("tool output should be json");
|
||||
assert_eq!(parsed["requested_ticker"], "BTC");
|
||||
assert_eq!(parsed["ticker"], "BTC-USD");
|
||||
assert_eq!(parsed["source"], CHART_SOURCE);
|
||||
assert_eq!(parsed["fallback_used"], true);
|
||||
assert_eq!(parsed["quote_type"], "CRYPTOCURRENCY");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_reports_invalid_symbol() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "NOTREAL"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"quoteResponse": {
|
||||
"result": []
|
||||
}
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/NOTREAL"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let err = tool
|
||||
.execute(json!({"ticker": "NOTREAL"}), &context().0)
|
||||
.await
|
||||
.expect_err("invalid symbol should error");
|
||||
|
||||
assert!(matches!(err, ToolError::InvalidInput { .. }));
|
||||
assert!(err.to_string().contains("NOTREAL"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_reports_upstream_failure_after_fallback() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "SPY"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/SPY"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let err = tool
|
||||
.execute(json!({"ticker": "SPY"}), &context().0)
|
||||
.await
|
||||
.expect_err("double upstream failure should error");
|
||||
|
||||
match err {
|
||||
ToolError::ExecutionFailed { message } => {
|
||||
assert!(message.contains(QUOTE_SOURCE));
|
||||
assert!(message.contains("HTTP 401"));
|
||||
assert!(message.contains(CHART_SOURCE));
|
||||
assert!(message.contains("HTTP 503"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_does_not_mask_upstream_failure_with_chart_not_found() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "SPY"))
|
||||
.respond_with(ResponseTemplate::new(503).set_body_string("service unavailable"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/SPY"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let err = tool
|
||||
.execute(json!({"ticker": "SPY"}), &context().0)
|
||||
.await
|
||||
.expect_err("mixed upstream/not-found failures should not look like an invalid symbol");
|
||||
|
||||
match err {
|
||||
ToolError::ExecutionFailed { message } => {
|
||||
assert!(message.contains(QUOTE_SOURCE));
|
||||
assert!(message.contains("HTTP 503"));
|
||||
assert!(message.contains(CHART_SOURCE));
|
||||
assert!(message.contains("HTTP 404"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_does_not_mask_quote_auth_failure_with_unknown_symbol() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "SPY"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/SPY"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let err = tool
|
||||
.execute(json!({"ticker": "SPY"}), &context().0)
|
||||
.await
|
||||
.expect_err("quote auth failures should not collapse into invalid input");
|
||||
|
||||
match err {
|
||||
ToolError::ExecutionFailed { message } => {
|
||||
assert!(message.contains(QUOTE_SOURCE));
|
||||
assert!(message.contains("HTTP 401"));
|
||||
assert!(message.contains(CHART_SOURCE));
|
||||
assert!(message.contains("HTTP 404"));
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_reports_timeout_when_fallback_times_out() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "AAPL"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/AAPL"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(250))
|
||||
.set_body_json(json!({
|
||||
"chart": {
|
||||
"result": [{
|
||||
"meta": {
|
||||
"symbol": "AAPL",
|
||||
"regularMarketPrice": 260.48,
|
||||
"chartPreviousClose": 255.92
|
||||
}
|
||||
}],
|
||||
"error": null
|
||||
}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let err = tool
|
||||
.execute(json!({"ticker": "AAPL", "timeout_ms": 1}), &context().0)
|
||||
.await
|
||||
.expect_err("timeout should surface cleanly");
|
||||
|
||||
assert!(matches!(err, ToolError::Timeout { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn finance_prefers_timeout_over_unknown_symbol_when_any_attempt_times_out() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/quote"))
|
||||
.and(query_param("symbols", "AAPL"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_delay(Duration::from_millis(250))
|
||||
.set_body_json(json!({
|
||||
"quoteResponse": {
|
||||
"result": [{
|
||||
"symbol": "AAPL",
|
||||
"regularMarketPrice": 189.23
|
||||
}]
|
||||
}
|
||||
})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chart/AAPL"))
|
||||
.and(query_param("interval", "1d"))
|
||||
.and(query_param("range", "5d"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let tool = tool_with_server(&server);
|
||||
let err = tool
|
||||
.execute(json!({"ticker": "AAPL", "timeout_ms": 1}), &context().0)
|
||||
.await
|
||||
.expect_err("timeout should win over a later chart not-found");
|
||||
|
||||
assert!(matches!(err, ToolError::Timeout { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finance_schema_allows_ticker_or_symbol() {
|
||||
let schema = FinanceTool::new().input_schema();
|
||||
let any_of = schema["anyOf"]
|
||||
.as_array()
|
||||
.expect("finance schema should advertise alternate required fields");
|
||||
|
||||
assert_eq!(any_of.len(), 2);
|
||||
assert_eq!(any_of[0]["required"], json!(["ticker"]));
|
||||
assert_eq!(any_of[1]["required"], json!(["symbol"]));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod apply_patch;
|
||||
pub mod diagnostics;
|
||||
pub mod file;
|
||||
pub mod file_search;
|
||||
pub mod finance;
|
||||
|
||||
pub mod git;
|
||||
pub mod git_history;
|
||||
|
||||
@@ -345,9 +345,11 @@ impl ToolRegistryBuilder {
|
||||
/// Include web search tools.
|
||||
#[must_use]
|
||||
pub fn with_web_tools(self) -> Self {
|
||||
use super::finance::FinanceTool;
|
||||
use super::web_run::WebRunTool;
|
||||
use super::web_search::WebSearchTool;
|
||||
self.with_tool(Arc::new(WebSearchTool))
|
||||
.with_tool(Arc::new(FinanceTool::new()))
|
||||
.with_tool(Arc::new(WebRunTool))
|
||||
}
|
||||
|
||||
@@ -711,4 +713,26 @@ mod tests {
|
||||
assert_eq!(readonly.len(), 1);
|
||||
assert_eq!(readonly[0].name(), "reader");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_with_web_tools_includes_finance() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
|
||||
let registry = ToolRegistryBuilder::new().with_web_tools().build(ctx);
|
||||
|
||||
assert!(registry.contains("finance"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_with_agent_tools_includes_finance() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let ctx = ToolContext::new(tmp.path().to_path_buf());
|
||||
|
||||
let registry = ToolRegistryBuilder::new()
|
||||
.with_agent_tools(false)
|
||||
.build(ctx);
|
||||
|
||||
assert!(registry.contains("finance"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +315,7 @@ pub struct App {
|
||||
pub show_thinking: bool,
|
||||
pub show_tool_details: bool,
|
||||
pub composer_density: ComposerDensity,
|
||||
pub composer_border: bool,
|
||||
pub transcript_spacing: TranscriptSpacing,
|
||||
pub sidebar_width_percent: u16,
|
||||
pub sidebar_focus: SidebarFocus,
|
||||
@@ -428,6 +429,8 @@ pub struct App {
|
||||
pub is_compacting: bool,
|
||||
/// Timestamp of the last user message send (for brief visual feedback).
|
||||
pub last_send_at: Option<Instant>,
|
||||
/// Cached footer clock label so idle sessions still repaint when the minute changes.
|
||||
pub footer_clock_label: String,
|
||||
}
|
||||
|
||||
/// Message queued while the engine is busy.
|
||||
@@ -520,6 +523,7 @@ impl App {
|
||||
let show_thinking = settings.show_thinking;
|
||||
let show_tool_details = settings.show_tool_details;
|
||||
let composer_density = ComposerDensity::from_setting(&settings.composer_density);
|
||||
let composer_border = settings.composer_border;
|
||||
let transcript_spacing = TranscriptSpacing::from_setting(&settings.transcript_spacing);
|
||||
let sidebar_width_percent = settings.sidebar_width_percent;
|
||||
let sidebar_focus = SidebarFocus::from_setting(&settings.sidebar_focus);
|
||||
@@ -603,6 +607,7 @@ impl App {
|
||||
show_thinking,
|
||||
show_tool_details,
|
||||
composer_density,
|
||||
composer_border,
|
||||
transcript_spacing,
|
||||
sidebar_width_percent,
|
||||
sidebar_focus,
|
||||
@@ -677,6 +682,7 @@ impl App {
|
||||
thinking_started_at: None,
|
||||
is_compacting: false,
|
||||
last_send_at: None,
|
||||
footer_clock_label: chrono::Local::now().format("%H:%M").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-138
@@ -7,6 +7,7 @@ use std::process::Command;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::Local;
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
@@ -842,7 +843,10 @@ async fn run_event_loop(
|
||||
let now = Instant::now();
|
||||
app.flush_paste_burst_if_due(now);
|
||||
app.sync_status_message_to_toasts();
|
||||
refresh_workspace_context_if_needed(app, now);
|
||||
sync_footer_clock(app);
|
||||
let allow_workspace_context_refresh =
|
||||
!app.is_loading && !has_running_agents && !app.is_compacting;
|
||||
refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh);
|
||||
|
||||
if app.needs_redraw {
|
||||
terminal.draw(|f| render(f, app))?; // app is &mut
|
||||
@@ -2155,19 +2159,13 @@ fn plan_choice_from_option(option: usize) -> Option<PlanChoice> {
|
||||
}
|
||||
|
||||
fn parse_plan_choice(input: &str) -> Option<PlanChoice> {
|
||||
let trimmed = input.trim().to_lowercase();
|
||||
let first = trimmed.chars().next()?;
|
||||
if let Some(digit) = first.to_digit(10)
|
||||
&& let Some(choice) = plan_choice_from_option(usize::try_from(digit).unwrap_or(0))
|
||||
{
|
||||
return Some(choice);
|
||||
}
|
||||
|
||||
match trimmed.as_str() {
|
||||
"accept" | "approve" | "agent" | "a" => Some(PlanChoice::AcceptAgent),
|
||||
"accept-yolo" | "yolo" | "y" => Some(PlanChoice::AcceptYolo),
|
||||
"revise" | "edit" | "plan" | "stay" => Some(PlanChoice::RevisePlan),
|
||||
"exit" | "cancel" | "back" => Some(PlanChoice::ExitPlan),
|
||||
// Once the modal is dismissed, only the advertised 1-4 fallback remains active.
|
||||
// Letter shortcuts stay modal-only so normal messages like "yolo" are not captured.
|
||||
match input.trim() {
|
||||
"1" => Some(PlanChoice::AcceptAgent),
|
||||
"2" => Some(PlanChoice::AcceptYolo),
|
||||
"3" => Some(PlanChoice::RevisePlan),
|
||||
"4" => Some(PlanChoice::ExitPlan),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -3077,7 +3075,7 @@ fn apply_loaded_session(app: &mut App, session: &SavedSession) {
|
||||
app.scroll_to_bottom();
|
||||
}
|
||||
|
||||
fn refresh_workspace_context_if_needed(app: &mut App, now: Instant) {
|
||||
fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_blocking_refresh: bool) {
|
||||
if app
|
||||
.workspace_context_refreshed_at
|
||||
.is_some_and(|refreshed_at| {
|
||||
@@ -3087,6 +3085,10 @@ fn refresh_workspace_context_if_needed(app: &mut App, now: Instant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !allow_blocking_refresh {
|
||||
return;
|
||||
}
|
||||
|
||||
app.workspace_context = collect_workspace_context(&app.workspace);
|
||||
app.workspace_context_refreshed_at = Some(now);
|
||||
}
|
||||
@@ -3429,66 +3431,31 @@ fn status_color(level: StatusToastLevel) -> ratatui::style::Color {
|
||||
|
||||
fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
let available_width = area.width as usize;
|
||||
|
||||
// Context percentage
|
||||
let context_snapshot = context_usage_snapshot(app);
|
||||
let percent = context_snapshot
|
||||
.map(|(_, _, pct)| pct as f32)
|
||||
.unwrap_or(0.0);
|
||||
let bar_color = context_color_for_percent(f64::from(percent));
|
||||
|
||||
// Narrow terminal fallback (< 60 cols): just mode + percent with mini bar
|
||||
if available_width < 60 {
|
||||
let (mode_label, mode_color) = footer_mode_style(app);
|
||||
let narrow_left = vec![Span::styled(
|
||||
mode_label.to_string(),
|
||||
Style::default().fg(mode_color),
|
||||
)];
|
||||
let narrow_right = context_bar_spans(percent, bar_color, false);
|
||||
|
||||
let nl_width: usize = narrow_left.iter().map(|s| s.content.width()).sum();
|
||||
let nr_width: usize = narrow_right.iter().map(|s| s.content.width()).sum();
|
||||
let ns_width = available_width.saturating_sub(nl_width + nr_width);
|
||||
|
||||
let mut all_spans = narrow_left;
|
||||
all_spans.push(Span::raw(" ".repeat(ns_width)));
|
||||
all_spans.extend(narrow_right);
|
||||
|
||||
let footer = Paragraph::new(Line::from(all_spans));
|
||||
f.render_widget(footer, area);
|
||||
if available_width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Right side: context percentage + mini bar + token count
|
||||
let show_tokens = available_width >= 80;
|
||||
let right_spans = context_bar_spans_with_tokens(
|
||||
percent,
|
||||
bar_color,
|
||||
show_tokens.then_some(context_snapshot).flatten(),
|
||||
);
|
||||
let right_width: usize = right_spans.iter().map(|s| s.content.width()).sum();
|
||||
|
||||
// Left side: toast or Kimi-style status line
|
||||
let percent = context_usage_snapshot(app)
|
||||
.map(|(_, _, pct)| pct)
|
||||
.unwrap_or(0.0);
|
||||
let right_spans = footer_context_spans(percent, available_width);
|
||||
let right_width = spans_width(&right_spans);
|
||||
let active_status = app.active_status_toast();
|
||||
let min_gap = if available_width < 60 { 1 } else { 2 };
|
||||
let max_left_width = available_width
|
||||
.saturating_sub(right_width)
|
||||
.saturating_sub(min_gap)
|
||||
.max(1);
|
||||
|
||||
let left_spans = if let Some(toast) = active_status.as_ref() {
|
||||
let max_left = available_width
|
||||
.saturating_sub(right_width)
|
||||
.saturating_sub(2)
|
||||
.max(1);
|
||||
let truncated = truncate_line_to_width(&toast.text, max_left);
|
||||
vec![Span::styled(
|
||||
truncated,
|
||||
Style::default().fg(status_color(toast.level)),
|
||||
)]
|
||||
footer_toast_spans(toast, max_left_width)
|
||||
} else if available_width < 60 {
|
||||
footer_narrow_status_spans(app, max_left_width)
|
||||
} else {
|
||||
let hint = footer_hint_text(app);
|
||||
vec![Span::styled(
|
||||
hint,
|
||||
Style::default().fg(palette::FOOTER_HINT),
|
||||
)]
|
||||
footer_status_line_spans(app, max_left_width)
|
||||
};
|
||||
|
||||
let left_width: usize = left_spans.iter().map(|s| s.content.width()).sum();
|
||||
let left_width = spans_width(&left_spans);
|
||||
let spacer_width = available_width.saturating_sub(left_width + right_width);
|
||||
|
||||
let mut all_spans = left_spans;
|
||||
@@ -3499,27 +3466,116 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) {
|
||||
f.render_widget(footer, area);
|
||||
}
|
||||
|
||||
fn footer_hint_text(app: &App) -> String {
|
||||
let slash_menu_open = !visible_slash_menu_entries(app, 1).is_empty();
|
||||
fn footer_toast_spans(
|
||||
toast: &crate::tui::app::StatusToast,
|
||||
max_width: usize,
|
||||
) -> Vec<Span<'static>> {
|
||||
let truncated = truncate_line_to_width(&toast.text, max_width.max(1));
|
||||
vec![Span::styled(
|
||||
truncated,
|
||||
Style::default().fg(status_color(toast.level)),
|
||||
)]
|
||||
}
|
||||
|
||||
fn footer_narrow_status_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
let (mode_label, mode_color) = footer_mode_style(app);
|
||||
let (status_label, status_color) = footer_state_label(app);
|
||||
let mode_width = mode_label.width();
|
||||
|
||||
if max_width <= mode_width || status_label == "ready" {
|
||||
return vec![Span::styled(
|
||||
truncate_line_to_width(mode_label, max_width.max(1)),
|
||||
Style::default().fg(mode_color),
|
||||
)];
|
||||
}
|
||||
|
||||
let status_width = max_width.saturating_sub(mode_width + 1);
|
||||
let truncated_status = truncate_line_to_width(status_label, status_width.max(1));
|
||||
|
||||
vec![
|
||||
Span::styled(mode_label.to_string(), Style::default().fg(mode_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(truncated_status, Style::default().fg(status_color)),
|
||||
]
|
||||
}
|
||||
|
||||
fn footer_status_line_spans(app: &App, max_width: usize) -> Vec<Span<'static>> {
|
||||
if max_width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let time_label = app.footer_clock_label.clone();
|
||||
let (mode_label, mode_color) = footer_mode_style(app);
|
||||
let (status_label, status_color) = footer_state_label(app);
|
||||
let fixed_width = time_label.width()
|
||||
+ 2
|
||||
+ mode_label.width()
|
||||
+ 2
|
||||
+ "agent (".width()
|
||||
+ ", ".width()
|
||||
+ status_label.width()
|
||||
+ 1;
|
||||
|
||||
if max_width <= fixed_width {
|
||||
return footer_narrow_status_spans(app, max_width);
|
||||
}
|
||||
|
||||
let model_width = max_width.saturating_sub(fixed_width).max(1);
|
||||
let model_label = truncate_line_to_width(&app.model, model_width);
|
||||
|
||||
vec![
|
||||
Span::styled(time_label, Style::default().fg(palette::TEXT_MUTED)),
|
||||
Span::raw(" "),
|
||||
Span::styled(mode_label.to_string(), Style::default().fg(mode_color)),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
"agent".to_string(),
|
||||
Style::default().fg(palette::FOOTER_HINT),
|
||||
),
|
||||
Span::styled(" (".to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
Span::styled(model_label, Style::default().fg(palette::TEXT_HINT)),
|
||||
Span::styled(", ".to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
Span::styled(status_label.to_string(), Style::default().fg(status_color)),
|
||||
Span::styled(")".to_string(), Style::default().fg(palette::TEXT_DIM)),
|
||||
]
|
||||
}
|
||||
|
||||
fn sync_footer_clock(app: &mut App) {
|
||||
sync_footer_clock_to(app, Local::now().format("%H:%M").to_string());
|
||||
}
|
||||
|
||||
fn sync_footer_clock_to(app: &mut App, time_label: String) {
|
||||
if app.footer_clock_label == time_label {
|
||||
return;
|
||||
}
|
||||
|
||||
app.footer_clock_label = time_label;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
fn footer_state_label(app: &App) -> (&'static str, ratatui::style::Color) {
|
||||
if app.is_compacting {
|
||||
return ("compacting", palette::STATUS_WARNING);
|
||||
}
|
||||
if app.is_loading {
|
||||
return ("thinking", palette::STATUS_WARNING);
|
||||
}
|
||||
if running_agent_count(app) > 0 {
|
||||
return ("working", palette::DEEPSEEK_SKY);
|
||||
}
|
||||
if app.queued_draft.is_some() {
|
||||
return ("draft", palette::TEXT_MUTED);
|
||||
}
|
||||
|
||||
if !app.view_stack.is_empty() {
|
||||
return "Esc close overlay".to_string();
|
||||
}
|
||||
if app.is_loading || app.is_compacting {
|
||||
return "Esc interrupt".to_string();
|
||||
}
|
||||
if slash_menu_open {
|
||||
return "Up/Down move · Tab accept".to_string();
|
||||
return ("overlay", palette::TEXT_MUTED);
|
||||
}
|
||||
|
||||
if !app.input.is_empty() {
|
||||
if app.input.contains('\n') {
|
||||
return "Enter send · Esc clear".to_string();
|
||||
}
|
||||
return "Enter send · Alt+Enter newline".to_string();
|
||||
return ("draft", palette::TEXT_MUTED);
|
||||
}
|
||||
if app.input_history.is_empty() {
|
||||
return "Ctrl+K commands · F1 help".to_string();
|
||||
}
|
||||
"Ctrl+K commands".to_string()
|
||||
|
||||
("ready", palette::TEXT_MUTED)
|
||||
}
|
||||
|
||||
fn footer_mode_style(app: &App) -> (&'static str, ratatui::style::Color) {
|
||||
@@ -3573,64 +3629,29 @@ fn context_color_for_percent(percent: f64) -> ratatui::style::Color {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build context bar spans: `42.0% ▮▮▮░░░░░`
|
||||
fn context_bar_spans(
|
||||
percent: f32,
|
||||
color: ratatui::style::Color,
|
||||
show_label: bool,
|
||||
) -> Vec<Span<'static>> {
|
||||
const BAR_WIDTH: u32 = 8;
|
||||
let filled = ((percent as f64 / 100.0) * BAR_WIDTH as f64)
|
||||
.round()
|
||||
.clamp(0.0, BAR_WIDTH as f64) as u32;
|
||||
let empty = BAR_WIDTH.saturating_sub(filled);
|
||||
fn footer_context_spans(percent: f64, max_width: usize) -> Vec<Span<'static>> {
|
||||
let color = context_color_for_percent(percent);
|
||||
let value = format!("{percent:.1}%");
|
||||
let full_width = "context: ".width() + value.width();
|
||||
|
||||
let bar_filled: String = "▮".repeat(filled as usize);
|
||||
let bar_empty: String = "░".repeat(empty as usize);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
if show_label {
|
||||
spans.push(Span::styled(
|
||||
format!("context: {percent:.1}% "),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
format!("{percent:.0}% "),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
if max_width >= full_width {
|
||||
return vec![
|
||||
Span::styled(
|
||||
"context: ".to_string(),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
),
|
||||
Span::styled(value, Style::default().fg(color)),
|
||||
];
|
||||
}
|
||||
spans.push(Span::styled(bar_filled, Style::default().fg(color)));
|
||||
spans.push(Span::styled(
|
||||
bar_empty,
|
||||
Style::default().fg(palette::BORDER_COLOR),
|
||||
));
|
||||
spans
|
||||
|
||||
vec![Span::styled(
|
||||
truncate_line_to_width(&value, max_width.max(1)),
|
||||
Style::default().fg(color),
|
||||
)]
|
||||
}
|
||||
|
||||
/// Build context bar spans with optional token count detail.
|
||||
fn context_bar_spans_with_tokens(
|
||||
percent: f32,
|
||||
color: ratatui::style::Color,
|
||||
snapshot: Option<(i64, u32, f64)>,
|
||||
) -> Vec<Span<'static>> {
|
||||
let mut spans = context_bar_spans(percent, color, true);
|
||||
|
||||
// Show token counts when available: " (12.3k/128k)"
|
||||
if let Some((used, max, _)) = snapshot {
|
||||
let used_u64 = u64::try_from(used.max(0)).unwrap_or(0);
|
||||
let max_u64 = u64::from(max);
|
||||
spans.push(Span::styled(
|
||||
format!(
|
||||
" ({}/{})",
|
||||
format_token_count_compact(used_u64),
|
||||
format_token_count_compact(max_u64)
|
||||
),
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
));
|
||||
}
|
||||
|
||||
spans
|
||||
fn spans_width(spans: &[Span<'_>]) -> usize {
|
||||
spans.iter().map(|span| span.content.width()).sum()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
+243
-10
@@ -1,7 +1,10 @@
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus};
|
||||
use crate::tui::views::{ModalView, ViewAction};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn selection_point_from_position_ignores_top_padding() {
|
||||
@@ -66,16 +69,11 @@ fn parse_plan_choice_accepts_numbers() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_plan_choice_accepts_aliases() {
|
||||
assert_eq!(parse_plan_choice("accept"), Some(PlanChoice::AcceptAgent));
|
||||
assert_eq!(parse_plan_choice("agent"), Some(PlanChoice::AcceptAgent));
|
||||
assert_eq!(
|
||||
parse_plan_choice("accept-yolo"),
|
||||
Some(PlanChoice::AcceptYolo)
|
||||
);
|
||||
assert_eq!(parse_plan_choice("yolo"), Some(PlanChoice::AcceptYolo));
|
||||
assert_eq!(parse_plan_choice("revise"), Some(PlanChoice::RevisePlan));
|
||||
assert_eq!(parse_plan_choice("exit"), Some(PlanChoice::ExitPlan));
|
||||
fn parse_plan_choice_rejects_aliases_and_extra_text() {
|
||||
assert_eq!(parse_plan_choice("accept"), None);
|
||||
assert_eq!(parse_plan_choice("agent"), None);
|
||||
assert_eq!(parse_plan_choice("yolo"), None);
|
||||
assert_eq!(parse_plan_choice("3 revise"), None);
|
||||
assert_eq!(parse_plan_choice("unknown"), None);
|
||||
}
|
||||
|
||||
@@ -88,6 +86,18 @@ fn plan_choice_from_option_maps_expected_values() {
|
||||
assert_eq!(plan_choice_from_option(5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_prompt_view_escape_emits_dismiss_event() {
|
||||
let mut view = PlanPromptView::new();
|
||||
|
||||
let action = view.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(matches!(
|
||||
action,
|
||||
ViewAction::EmitAndClose(ViewEvent::PlanPromptDismissed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_scroll_percent_is_clamped_and_relative() {
|
||||
assert_eq!(transcript_scroll_percent(0, 20, 120), Some(0));
|
||||
@@ -116,6 +126,50 @@ fn create_test_app() -> App {
|
||||
App::new(options, &Config::default())
|
||||
}
|
||||
|
||||
fn init_git_repo() -> TempDir {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let init = Command::new("git")
|
||||
.arg("init")
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
assert!(
|
||||
init.status.success(),
|
||||
"git init failed: {}",
|
||||
String::from_utf8_lossy(&init.stderr)
|
||||
);
|
||||
|
||||
let commit = Command::new("git")
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=DeepSeek TUI Tests",
|
||||
"-c",
|
||||
"user.email=tests@example.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"init",
|
||||
])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.expect("git commit should run");
|
||||
assert!(
|
||||
commit.status.success(),
|
||||
"git commit failed: {}",
|
||||
String::from_utf8_lossy(&commit.stderr)
|
||||
);
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
fn spans_text(spans: &[Span<'_>]) -> String {
|
||||
spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_4_switches_to_plan_mode() {
|
||||
let mut app = create_test_app();
|
||||
@@ -275,6 +329,74 @@ fn format_context_budget_caps_overflow_display() {
|
||||
assert_eq!(format_context_budget(250_000, 128_000), ">128.0k/128.0k");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_state_label_prefers_compacting_then_thinking() {
|
||||
let mut app = create_test_app();
|
||||
assert_eq!(footer_state_label(&app).0, "ready");
|
||||
|
||||
app.is_loading = true;
|
||||
assert_eq!(footer_state_label(&app).0, "thinking");
|
||||
|
||||
app.is_compacting = true;
|
||||
assert_eq!(footer_state_label(&app).0, "compacting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_context_spans_uses_decimal_context_label() {
|
||||
let full = spans_text(&footer_context_spans(12.34, 32));
|
||||
assert_eq!(full, "context: 12.3%");
|
||||
|
||||
let compact = spans_text(&footer_context_spans(12.34, 6));
|
||||
assert_eq!(compact, "12.3%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_narrow_status_spans_hides_ready_state_but_shows_activity() {
|
||||
let mut app = create_test_app();
|
||||
assert_eq!(spans_text(&footer_narrow_status_spans(&app, 24)), "agent");
|
||||
|
||||
app.is_loading = true;
|
||||
assert_eq!(
|
||||
spans_text(&footer_narrow_status_spans(&app, 24)),
|
||||
"agent thinking"
|
||||
);
|
||||
|
||||
app.is_loading = false;
|
||||
app.is_compacting = true;
|
||||
assert_eq!(
|
||||
spans_text(&footer_narrow_status_spans(&app, 24)),
|
||||
"agent compacting"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_status_line_spans_truncate_long_model_names() {
|
||||
let mut app = create_test_app();
|
||||
app.model = "deepseek-reasoner-with-an-extremely-long-model-name".to_string();
|
||||
app.is_loading = true;
|
||||
|
||||
let line = spans_text(&footer_status_line_spans(&app, 48));
|
||||
assert!(line.contains("agent ("));
|
||||
assert!(line.contains(", thinking)"));
|
||||
assert!(line.contains("..."));
|
||||
assert!(UnicodeWidthStr::width(line.as_str()) <= 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_footer_clock_to_marks_redraw_only_when_minute_changes() {
|
||||
let mut app = create_test_app();
|
||||
app.footer_clock_label = "12:00".to_string();
|
||||
app.needs_redraw = false;
|
||||
|
||||
sync_footer_clock_to(&mut app, "12:00".to_string());
|
||||
assert_eq!(app.footer_clock_label, "12:00");
|
||||
assert!(!app.needs_redraw);
|
||||
|
||||
sync_footer_clock_to(&mut app, "12:01".to_string());
|
||||
assert_eq!(app.footer_clock_label, "12:01");
|
||||
assert!(app.needs_redraw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
|
||||
let mut app = create_test_app();
|
||||
@@ -504,6 +626,117 @@ fn status_layout_budget_preserves_chat_and_composer_on_tiny_heights() {
|
||||
assert_eq!(layout.status_height, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_context_refresh_is_deferred_while_ui_is_busy() {
|
||||
let repo = init_git_repo();
|
||||
let mut app = create_test_app();
|
||||
app.workspace = repo.path().to_path_buf();
|
||||
|
||||
let now = Instant::now();
|
||||
refresh_workspace_context_if_needed(&mut app, now, false);
|
||||
|
||||
assert!(app.workspace_context.is_none());
|
||||
assert!(app.workspace_context_refreshed_at.is_none());
|
||||
|
||||
refresh_workspace_context_if_needed(&mut app, now, true);
|
||||
|
||||
let context = app
|
||||
.workspace_context
|
||||
.as_deref()
|
||||
.expect("idle refresh should populate workspace context");
|
||||
assert!(context.contains("clean"));
|
||||
assert_eq!(app.workspace_context_refreshed_at, Some(now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_context_refresh_respects_ttl_before_requerying_git() {
|
||||
let repo = init_git_repo();
|
||||
let mut app = create_test_app();
|
||||
app.workspace = repo.path().to_path_buf();
|
||||
|
||||
let start = Instant::now();
|
||||
refresh_workspace_context_if_needed(&mut app, start, true);
|
||||
let initial = app
|
||||
.workspace_context
|
||||
.clone()
|
||||
.expect("initial refresh should populate context");
|
||||
|
||||
std::fs::write(repo.path().join("dirty.txt"), "dirty").expect("write dirty marker");
|
||||
|
||||
let before_ttl = start + Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS - 1);
|
||||
refresh_workspace_context_if_needed(&mut app, before_ttl, true);
|
||||
assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));
|
||||
|
||||
let after_ttl = start + Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS);
|
||||
refresh_workspace_context_if_needed(&mut app, after_ttl, true);
|
||||
let refreshed = app
|
||||
.workspace_context
|
||||
.as_deref()
|
||||
.expect("refresh after ttl should update context");
|
||||
assert!(refreshed.contains("untracked"));
|
||||
assert_ne!(refreshed, initial);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() {
|
||||
let mut app = create_test_app();
|
||||
app.mode = AppMode::Plan;
|
||||
app.plan_prompt_pending = true;
|
||||
app.offline_mode = true;
|
||||
|
||||
let engine = crate::core::engine::mock_engine_handle();
|
||||
|
||||
let handled = handle_plan_choice(&mut app, &engine.handle, "yolo")
|
||||
.await
|
||||
.expect("plan choice");
|
||||
|
||||
assert!(!handled);
|
||||
assert!(!app.plan_prompt_pending);
|
||||
assert_eq!(app.mode, AppMode::Plan);
|
||||
|
||||
let queued = build_queued_message(&mut app, "yolo".to_string());
|
||||
submit_or_steer_message(&mut app, &engine.handle, queued)
|
||||
.await
|
||||
.expect("submit normal message");
|
||||
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert_eq!(
|
||||
app.queued_messages
|
||||
.front()
|
||||
.map(crate::tui::app::QueuedMessage::content),
|
||||
Some("yolo".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
app.status_message.as_deref(),
|
||||
Some("Offline mode: queued 1 message(s) - /queue to review")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn numeric_plan_choice_still_queues_follow_up_when_busy() {
|
||||
let mut app = create_test_app();
|
||||
app.mode = AppMode::Plan;
|
||||
app.plan_prompt_pending = true;
|
||||
app.is_loading = true;
|
||||
|
||||
let engine = crate::core::engine::mock_engine_handle();
|
||||
|
||||
let handled = handle_plan_choice(&mut app, &engine.handle, "2")
|
||||
.await
|
||||
.expect("plan choice");
|
||||
|
||||
assert!(handled);
|
||||
assert!(!app.plan_prompt_pending);
|
||||
assert_eq!(app.mode, AppMode::Yolo);
|
||||
assert_eq!(app.queued_message_count(), 1);
|
||||
assert_eq!(
|
||||
app.queued_messages
|
||||
.front()
|
||||
.map(crate::tui::app::QueuedMessage::content),
|
||||
Some("Proceed with the accepted plan.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_validation_warns_without_blocking_unusual_formats() {
|
||||
assert!(matches!(
|
||||
|
||||
@@ -333,6 +333,12 @@ impl ConfigView {
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "composer_border".to_string(),
|
||||
value: settings.composer_border.to_string(),
|
||||
editable: true,
|
||||
scope: ConfigScope::Saved,
|
||||
},
|
||||
ConfigRow {
|
||||
key: "transcript_spacing".to_string(),
|
||||
value: settings.transcript_spacing.clone(),
|
||||
@@ -577,9 +583,8 @@ fn config_hint_for_key(key: &str) -> &'static str {
|
||||
"deepseek-chat | deepseek-reasoner | deepseek-* (aliases: deepseek-v3, deepseek-v3.2, deepseek-r1)"
|
||||
}
|
||||
"approval_mode" => "auto | suggest | never",
|
||||
"auto_compact" | "calm_mode" | "low_motion" | "show_thinking" | "show_tool_details" => {
|
||||
"on/off, true/false, yes/no, 1/0"
|
||||
}
|
||||
"auto_compact" | "calm_mode" | "low_motion" | "show_thinking" | "show_tool_details"
|
||||
| "composer_border" => "on/off, true/false, yes/no, 1/0",
|
||||
"composer_density" | "transcript_spacing" => "compact | comfortable | spacious",
|
||||
"default_mode" => "agent | plan | yolo",
|
||||
"theme" => "default | dark | light | whale",
|
||||
@@ -1545,6 +1550,7 @@ mod tests {
|
||||
assert!(keys.contains(&"model"));
|
||||
assert!(keys.contains(&"approval_mode"));
|
||||
assert!(keys.contains(&"auto_compact"));
|
||||
assert!(keys.contains(&"composer_border"));
|
||||
assert!(view.rows.iter().all(|row| row.editable));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
//! Header bar widget displaying mode, model, and streaming state.
|
||||
//! Header bar widget displaying mode, workspace/model context, and session status.
|
||||
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Modifier, Style},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::palette;
|
||||
use crate::tui::app::AppMode;
|
||||
|
||||
use super::Renderable;
|
||||
|
||||
const CONTEXT_WARNING_THRESHOLD_PERCENT: f64 = 85.0;
|
||||
const CONTEXT_CRITICAL_THRESHOLD_PERCENT: f64 = 95.0;
|
||||
const CONTEXT_SIGNAL_WIDTH: usize = 4;
|
||||
|
||||
/// Data required to render the header bar.
|
||||
pub struct HeaderData<'a> {
|
||||
pub model: &'a str,
|
||||
@@ -72,8 +76,6 @@ impl<'a> HeaderData<'a> {
|
||||
}
|
||||
|
||||
/// Header bar widget (1 line height).
|
||||
///
|
||||
/// Layout: `mode model ●`
|
||||
pub struct HeaderWidget<'a> {
|
||||
data: HeaderData<'a>,
|
||||
}
|
||||
@@ -84,8 +86,7 @@ impl<'a> HeaderWidget<'a> {
|
||||
Self { data }
|
||||
}
|
||||
|
||||
/// Get the color for a mode.
|
||||
fn mode_color(mode: AppMode) -> ratatui::style::Color {
|
||||
fn mode_color(mode: AppMode) -> Color {
|
||||
match mode {
|
||||
AppMode::Agent => palette::MODE_AGENT,
|
||||
AppMode::Yolo => palette::MODE_YOLO,
|
||||
@@ -101,53 +102,232 @@ impl<'a> HeaderWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_segments(&self) -> Vec<Span<'static>> {
|
||||
let modes = [AppMode::Plan, AppMode::Agent, AppMode::Yolo];
|
||||
let mut spans = Vec::new();
|
||||
for (idx, mode) in modes.into_iter().enumerate() {
|
||||
if idx > 0 {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
let is_selected = mode == self.data.mode;
|
||||
let style = if is_selected {
|
||||
Style::default()
|
||||
.fg(self.data.background)
|
||||
.bg(Self::mode_color(mode))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(palette::TEXT_HINT)
|
||||
};
|
||||
spans.push(Span::styled(format!(" {} ", Self::mode_name(mode)), style));
|
||||
fn span_width(spans: &[Span<'_>]) -> usize {
|
||||
spans.iter().map(|span| span.content.width()).sum()
|
||||
}
|
||||
|
||||
fn truncate_to_width(text: &str, max_width: usize) -> String {
|
||||
const ELLIPSIS: &str = "...";
|
||||
let ellipsis_width = ELLIPSIS.width();
|
||||
|
||||
if text.width() <= max_width {
|
||||
return text.to_string();
|
||||
}
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if max_width <= ellipsis_width {
|
||||
return ".".repeat(max_width);
|
||||
}
|
||||
|
||||
let mut truncated = String::new();
|
||||
let mut width = 0;
|
||||
for ch in text.chars() {
|
||||
let ch_width = ch.width().unwrap_or(0);
|
||||
if width + ch_width + ellipsis_width > max_width {
|
||||
break;
|
||||
}
|
||||
truncated.push(ch);
|
||||
width += ch_width;
|
||||
}
|
||||
truncated.push_str(ELLIPSIS);
|
||||
truncated
|
||||
}
|
||||
|
||||
fn context_percent(&self) -> Option<f64> {
|
||||
let used = f64::from(self.data.last_prompt_tokens?);
|
||||
let max = f64::from(self.data.context_window?);
|
||||
if max <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((used / max * 100.0).clamp(0.0, 999.0))
|
||||
}
|
||||
|
||||
fn context_color(percent: f64) -> Color {
|
||||
if percent >= CONTEXT_CRITICAL_THRESHOLD_PERCENT {
|
||||
palette::STATUS_ERROR
|
||||
} else if percent >= CONTEXT_WARNING_THRESHOLD_PERCENT {
|
||||
palette::STATUS_WARNING
|
||||
} else {
|
||||
palette::DEEPSEEK_SKY
|
||||
}
|
||||
}
|
||||
|
||||
fn context_signal_spans(&self, show_percent: bool) -> Vec<Span<'static>> {
|
||||
let Some(percent) = self.context_percent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let color = Self::context_color(percent);
|
||||
let filled = ((percent / 100.0) * CONTEXT_SIGNAL_WIDTH as f64)
|
||||
.ceil()
|
||||
.clamp(0.0, CONTEXT_SIGNAL_WIDTH as f64) as usize;
|
||||
let empty = CONTEXT_SIGNAL_WIDTH.saturating_sub(filled);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
if show_percent {
|
||||
spans.push(Span::styled(
|
||||
format!("{percent:.0}%"),
|
||||
Style::default().fg(color),
|
||||
));
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.push(Span::styled("▰".repeat(filled), Style::default().fg(color)));
|
||||
spans.push(Span::styled(
|
||||
"▱".repeat(empty),
|
||||
Style::default().fg(palette::BORDER_COLOR),
|
||||
));
|
||||
spans
|
||||
}
|
||||
|
||||
fn context_text(&self, max_chars: usize) -> String {
|
||||
let raw = format!("{} · {}", self.data.workspace_name, self.data.model);
|
||||
if raw.chars().count() <= max_chars {
|
||||
raw
|
||||
} else {
|
||||
let mut truncated = String::new();
|
||||
for ch in raw.chars().take(max_chars.saturating_sub(3)) {
|
||||
truncated.push(ch);
|
||||
fn status_variant(&self, show_stream_label: bool, show_percent: bool) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
if self.data.is_streaming {
|
||||
spans.push(Span::styled(
|
||||
"●",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if show_stream_label {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(
|
||||
"Live",
|
||||
Style::default().fg(palette::TEXT_SOFT),
|
||||
));
|
||||
}
|
||||
truncated.push_str("...");
|
||||
truncated
|
||||
}
|
||||
|
||||
let context_spans = self.context_signal_spans(show_percent);
|
||||
if !context_spans.is_empty() {
|
||||
if !spans.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
}
|
||||
spans.extend(context_spans);
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
/// Build the streaming indicator span.
|
||||
fn streaming_indicator(&self) -> Option<Span<'static>> {
|
||||
if !self.data.is_streaming {
|
||||
return None;
|
||||
fn right_spans(&self, max_width: usize) -> Vec<Span<'static>> {
|
||||
let candidates = [
|
||||
self.status_variant(true, true),
|
||||
self.status_variant(false, true),
|
||||
self.status_variant(false, false),
|
||||
];
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|spans| Self::span_width(spans) <= max_width)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn metadata_spans(&self, max_width: usize) -> Vec<Span<'static>> {
|
||||
let workspace = self.data.workspace_name.trim();
|
||||
let model = self.data.model.trim();
|
||||
|
||||
if max_width < 4 || (workspace.is_empty() && model.is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
Some(Span::styled(
|
||||
"●",
|
||||
Style::default()
|
||||
.fg(palette::DEEPSEEK_SKY)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
if workspace.is_empty() {
|
||||
return vec![Span::styled(
|
||||
Self::truncate_to_width(model, max_width),
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
)];
|
||||
}
|
||||
|
||||
if model.is_empty() || max_width < 12 {
|
||||
return vec![Span::styled(
|
||||
Self::truncate_to_width(workspace, max_width),
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
)];
|
||||
}
|
||||
|
||||
let separator_width = 3; // " · "
|
||||
if workspace.width() + separator_width + model.width() <= max_width {
|
||||
return vec![
|
||||
Span::styled(
|
||||
workspace.to_string(),
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
),
|
||||
Span::styled(" · ", Style::default().fg(palette::TEXT_HINT)),
|
||||
Span::styled(model.to_string(), Style::default().fg(palette::TEXT_HINT)),
|
||||
];
|
||||
}
|
||||
|
||||
let content_width = max_width.saturating_sub(separator_width);
|
||||
if content_width < 9 {
|
||||
return vec![Span::styled(
|
||||
Self::truncate_to_width(workspace, max_width),
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
)];
|
||||
}
|
||||
|
||||
let workspace_width = workspace.width();
|
||||
let model_width = model.width();
|
||||
let total_width = workspace_width + model_width;
|
||||
let min_workspace = 4;
|
||||
let min_model = 4;
|
||||
|
||||
let proportional_workspace =
|
||||
((content_width as f64 * workspace_width as f64) / total_width as f64).round() as usize;
|
||||
let workspace_budget =
|
||||
proportional_workspace.clamp(min_workspace, content_width.saturating_sub(min_model));
|
||||
let model_budget = content_width.saturating_sub(workspace_budget);
|
||||
|
||||
vec![
|
||||
Span::styled(
|
||||
Self::truncate_to_width(workspace, workspace_budget),
|
||||
Style::default().fg(palette::TEXT_SECONDARY),
|
||||
),
|
||||
Span::styled(" · ", Style::default().fg(palette::TEXT_HINT)),
|
||||
Span::styled(
|
||||
Self::truncate_to_width(model, model_budget),
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn left_spans(&self, max_width: usize) -> Vec<Span<'static>> {
|
||||
if max_width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mode_label = Self::mode_name(self.data.mode);
|
||||
let mode_style = Style::default()
|
||||
.fg(Self::mode_color(self.data.mode))
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
if max_width < mode_label.width() {
|
||||
let fallback = self
|
||||
.data
|
||||
.mode
|
||||
.label()
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_string();
|
||||
return vec![Span::styled(fallback, mode_style)];
|
||||
}
|
||||
|
||||
let mut spans = vec![Span::styled(mode_label.to_string(), mode_style)];
|
||||
let metadata_width = max_width
|
||||
.saturating_sub(mode_label.width())
|
||||
.saturating_sub(2);
|
||||
let metadata = if metadata_width >= 4 {
|
||||
self.metadata_spans(metadata_width)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
if !metadata.is_empty() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.extend(metadata);
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,71 +337,21 @@ impl Renderable for HeaderWidget<'_> {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut left_spans = self.mode_segments();
|
||||
|
||||
// Build right section: streaming indicator only. Footer owns context.
|
||||
let streaming_span = self.streaming_indicator();
|
||||
|
||||
// Calculate widths
|
||||
let mut left_width: usize = left_spans.iter().map(|span| span.content.width()).sum();
|
||||
let streaming_width = streaming_span.as_ref().map_or(0, |s| s.content.width());
|
||||
let right_width = streaming_width;
|
||||
|
||||
let available = area.width as usize;
|
||||
let right_budget = available.saturating_sub(6);
|
||||
let right_spans = self.right_spans(right_budget);
|
||||
let right_width = Self::span_width(&right_spans);
|
||||
let spacer_min = usize::from(right_width > 0);
|
||||
let left_budget = available.saturating_sub(right_width + spacer_min);
|
||||
let left_spans = self.left_spans(left_budget);
|
||||
let left_width = Self::span_width(&left_spans);
|
||||
let spacer_width = available.saturating_sub(left_width + right_width);
|
||||
|
||||
// Build final line based on available space
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let context_room = available
|
||||
.saturating_sub(left_width + right_width)
|
||||
.saturating_sub(2);
|
||||
if context_room >= 10 {
|
||||
let context_text = self.context_text(context_room);
|
||||
left_spans.push(Span::raw(" "));
|
||||
left_spans.push(Span::styled(
|
||||
context_text,
|
||||
Style::default().fg(palette::TEXT_HINT),
|
||||
));
|
||||
left_width = left_spans.iter().map(|span| span.content.width()).sum();
|
||||
}
|
||||
|
||||
if available >= left_width + right_width {
|
||||
spans.extend(left_spans);
|
||||
|
||||
// Spacer to push right elements to the end
|
||||
let padding_needed = available.saturating_sub(left_width + right_width);
|
||||
if padding_needed > 0 {
|
||||
spans.push(Span::raw(" ".repeat(padding_needed)));
|
||||
}
|
||||
|
||||
// Streaming indicator
|
||||
if let Some(streaming) = streaming_span {
|
||||
spans.push(streaming);
|
||||
}
|
||||
} else if available >= 12 {
|
||||
spans.push(Span::styled(
|
||||
format!(" {} ", Self::mode_name(self.data.mode)),
|
||||
Style::default()
|
||||
.fg(self.data.background)
|
||||
.bg(Self::mode_color(self.data.mode))
|
||||
.add_modifier(Modifier::BOLD),
|
||||
));
|
||||
} else {
|
||||
// Ultra-minimal: single lowercase char
|
||||
let first_char = self
|
||||
.data
|
||||
.mode
|
||||
.label()
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_lowercase()
|
||||
.to_string();
|
||||
spans.push(Span::styled(
|
||||
first_char,
|
||||
Style::default().fg(Self::mode_color(self.data.mode)),
|
||||
));
|
||||
let mut spans = left_spans;
|
||||
if spacer_width > 0 {
|
||||
spans.push(Span::raw(" ".repeat(spacer_width)));
|
||||
}
|
||||
spans.extend(right_spans);
|
||||
|
||||
let line = Line::from(spans);
|
||||
let paragraph = Paragraph::new(line).style(Style::default().bg(self.data.background));
|
||||
@@ -229,6 +359,98 @@ impl Renderable for HeaderWidget<'_> {
|
||||
}
|
||||
|
||||
fn desired_height(&self, _width: u16) -> u16 {
|
||||
1 // Header is always 1 line
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{HeaderData, HeaderWidget, Renderable};
|
||||
use crate::palette;
|
||||
use crate::tui::app::AppMode;
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
fn render_header(data: HeaderData<'_>, width: u16) -> String {
|
||||
let widget = HeaderWidget::new(data);
|
||||
let area = Rect::new(0, 0, width, 1);
|
||||
let mut buf = Buffer::empty(area);
|
||||
widget.render(area, &mut buf);
|
||||
|
||||
(0..width).map(|x| buf[(x, 0)].symbol()).collect::<String>()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_header_shows_plain_mode_and_single_metadata_cluster() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Agent,
|
||||
"deepseek-v3.2",
|
||||
"deepseek-tui",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
),
|
||||
72,
|
||||
);
|
||||
|
||||
assert!(rendered.contains("Agent"));
|
||||
assert!(rendered.contains("deepseek-tui"));
|
||||
assert!(rendered.contains("deepseek-v3.2"));
|
||||
assert!(!rendered.contains("Plan"));
|
||||
assert!(!rendered.contains("Yolo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_header_integrates_live_state_with_context_signal() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Plan,
|
||||
"deepseek-reasoner",
|
||||
"workspace",
|
||||
true,
|
||||
palette::DEEPSEEK_INK,
|
||||
)
|
||||
.with_usage(42_000, Some(128_000), 0.0, Some(48_000)),
|
||||
72,
|
||||
);
|
||||
|
||||
assert!(rendered.contains("Live"));
|
||||
assert!(rendered.contains("38%"));
|
||||
assert!(rendered.contains("▰"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_header_falls_back_to_mode_without_rendering_all_modes() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Yolo,
|
||||
"deepseek-chat",
|
||||
"repo",
|
||||
true,
|
||||
palette::DEEPSEEK_INK,
|
||||
)
|
||||
.with_usage(1_000, Some(10_000), 0.0, Some(4_000)),
|
||||
8,
|
||||
);
|
||||
|
||||
assert!(rendered.trim_start().starts_with('Y'));
|
||||
assert!(!rendered.contains("Plan"));
|
||||
assert!(!rendered.contains("Agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_hides_context_signal_when_usage_snapshot_is_missing() {
|
||||
let rendered = render_header(
|
||||
HeaderData::new(
|
||||
AppMode::Agent,
|
||||
"deepseek-chat",
|
||||
"repo",
|
||||
false,
|
||||
palette::DEEPSEEK_INK,
|
||||
),
|
||||
48,
|
||||
);
|
||||
|
||||
assert!(!rendered.contains('%'));
|
||||
assert!(!rendered.contains("▰"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,12 +138,12 @@ impl<'a> ComposerWidget<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_panel(area: Rect) -> bool {
|
||||
area.height >= 3 && area.width >= 12
|
||||
fn has_panel(&self, area: Rect) -> bool {
|
||||
self.app.composer_border && area.height >= 3 && area.width >= 12
|
||||
}
|
||||
|
||||
fn inner_area(area: Rect) -> Rect {
|
||||
if Self::has_panel(area) {
|
||||
fn inner_area(&self, area: Rect) -> Rect {
|
||||
if self.has_panel(area) {
|
||||
Block::default().borders(Borders::ALL).inner(area)
|
||||
} else {
|
||||
area
|
||||
@@ -166,8 +166,8 @@ impl<'a> ComposerWidget<'a> {
|
||||
impl Renderable for ComposerWidget<'_> {
|
||||
fn render(&self, area: Rect, buf: &mut Buffer) {
|
||||
let background = Style::default().bg(self.app.ui_theme.composer_bg);
|
||||
let has_panel = Self::has_panel(area);
|
||||
let inner_area = Self::inner_area(area);
|
||||
let has_panel = self.has_panel(area);
|
||||
let inner_area = self.inner_area(area);
|
||||
let menu_lines = self.slash_menu_entries.len();
|
||||
let input_rows_budget = composer_input_rows_budget(inner_area.height, menu_lines);
|
||||
let content_width = usize::from(inner_area.width.max(1));
|
||||
@@ -278,11 +278,12 @@ impl Renderable for ComposerWidget<'_> {
|
||||
self.max_height.min(self.max_height_cap()),
|
||||
self.slash_menu_entries.len(),
|
||||
self.app.composer_density,
|
||||
self.app.composer_border,
|
||||
)
|
||||
}
|
||||
|
||||
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
|
||||
let inner_area = Self::inner_area(area);
|
||||
let inner_area = self.inner_area(area);
|
||||
let content_width = usize::from(inner_area.width.max(1));
|
||||
let input_rows_budget =
|
||||
composer_input_rows_budget(inner_area.height, self.slash_menu_entries.len());
|
||||
@@ -841,8 +842,9 @@ fn composer_height(
|
||||
available_height: u16,
|
||||
extra_lines: usize,
|
||||
density: ComposerDensity,
|
||||
show_panel: bool,
|
||||
) -> u16 {
|
||||
let has_panel = available_height >= 3 && width >= 12;
|
||||
let has_panel = show_panel && available_height >= 3 && width >= 12;
|
||||
let chrome_height = if has_panel {
|
||||
usize::from(COMPOSER_PANEL_HEIGHT)
|
||||
} else {
|
||||
@@ -1261,6 +1263,7 @@ mod tests {
|
||||
available_height,
|
||||
menu_lines,
|
||||
ComposerDensity::Comfortable,
|
||||
true,
|
||||
);
|
||||
let has_panel = available_height >= 3 && width >= 12;
|
||||
let chrome_height = if has_panel {
|
||||
@@ -1293,10 +1296,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn composer_height_prefers_panel_shape_when_space_allows() {
|
||||
let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable);
|
||||
let height = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
|
||||
assert_eq!(height, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_height_skips_panel_chrome_when_border_disabled() {
|
||||
let with_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, true);
|
||||
let without_border = composer_height("", 40, 8, 0, ComposerDensity::Comfortable, false);
|
||||
|
||||
assert_eq!(with_border, 5);
|
||||
assert_eq!(without_border, 1);
|
||||
assert!(without_border < with_border);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_density_changes_min_rows_and_height_cap() {
|
||||
assert_eq!(composer_min_input_rows(ComposerDensity::Compact), 2);
|
||||
@@ -1357,6 +1370,24 @@ mod tests {
|
||||
assert_eq!(widget.cursor_pos(area), Some((1, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_composer_cursor_uses_full_area_when_border_disabled() {
|
||||
let mut app = create_test_app();
|
||||
app.composer_density = ComposerDensity::Comfortable;
|
||||
app.composer_border = false;
|
||||
let slash_menu_entries = Vec::<String>::new();
|
||||
let widget = ComposerWidget::new(&app, 3, &slash_menu_entries);
|
||||
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 40,
|
||||
height: 3,
|
||||
};
|
||||
|
||||
assert_eq!(widget.cursor_pos(area), Some((0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_top_padding_uses_clamp() {
|
||||
// content_lines=0 is clamped to 1
|
||||
|
||||
@@ -29,7 +29,7 @@ Current packaging note:
|
||||
## Version Coordination
|
||||
|
||||
- Rust crates inherit the shared workspace version from [Cargo.toml](../Cargo.toml).
|
||||
- Internal path dependency versions should match that workspace version; stale `0.3.30` pins are release blockers once the workspace moves to `0.3.31+`.
|
||||
- Internal path dependency versions should match the shared workspace version; stale older pins are release blockers once the workspace version moves.
|
||||
- The npm wrapper version lives in [npm/deepseek-tui/package.json](../npm/deepseek-tui/package.json).
|
||||
- `deepseekBinaryVersion` controls which GitHub release binaries the npm wrapper downloads.
|
||||
- Packaging-only npm releases are allowed:
|
||||
@@ -82,9 +82,11 @@ directory with a full asset matrix fixture before starting the server:
|
||||
```bash
|
||||
DEEPSEEK_TUI_PREPARE_ALL_ASSETS=1 node scripts/release/prepare-local-release-assets.js
|
||||
cd npm/deepseek-tui
|
||||
DEEPSEEK_TUI_VERSION=0.3.31 DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm run release:check
|
||||
DEEPSEEK_TUI_VERSION=X.Y.Z DEEPSEEK_TUI_RELEASE_BASE_URL=http://127.0.0.1:8123/ npm run release:check
|
||||
```
|
||||
|
||||
Set `DEEPSEEK_TUI_VERSION` to the npm package version you are verifying for that local run.
|
||||
|
||||
The CI workflow runs the same tarball install + smoke test on Linux and macOS.
|
||||
|
||||
## Rust Crates Release
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# DeepSeek Workspace Migration Status
|
||||
|
||||
This document maps the initial workspace migration implementation to Linear issues `SHA-1554` to `SHA-1568`.
|
||||
This document is a historical snapshot of the initial workspace migration implementation for Linear issues `SHA-1554` to `SHA-1568`.
|
||||
|
||||
## Implemented in this patch
|
||||
It is not maintained as a live status board. Some items below describe work that was still in progress at the time this patch landed and may no longer reflect the current codebase. For current behavior, use the active docs in `docs/` and the current source tree.
|
||||
|
||||
## Implemented in the initial patch
|
||||
|
||||
- `SHA-1554`:
|
||||
- Root converted to Cargo workspace.
|
||||
@@ -78,7 +80,7 @@ This document maps the initial workspace migration implementation to Linear issu
|
||||
- Added matching release preflight parity gates in `.github/workflows/release.yml`.
|
||||
- Updated release artifact naming to include explicit `deepseek` entrypoint compatibility.
|
||||
|
||||
## Not yet implemented in this patch
|
||||
## Open items at the time of the initial patch
|
||||
|
||||
- Codex-level protocol field-by-field parity for every `thread/*` operation remains in progress.
|
||||
- MCP transport now provides stdio JSON-RPC compatibility flows; external subprocess execution remains scaffolded.
|
||||
Reference in New Issue
Block a user