Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
uses: swatinem/rust-cache@v2

- name: Run Clippy
run: cargo clippy --all-targets -- -D warnings
run: cargo clippy --all-targets --all-features -- -D warnings

build-and-test:
name: Build & Test
Expand All @@ -57,7 +57,7 @@ jobs:
uses: swatinem/rust-cache@v2

- name: Build
run: cargo build --all-targets
run: cargo build --all-targets --all-features

- name: Run tests
run: cargo test --all-targets
run: cargo test --all-targets --all-features
132 changes: 131 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,131 @@
# qmtui
# qmtui

`qmtui` is the Rust terminal interface for QueryMT agents. It connects to an
Agent Client Protocol (ACP) endpoint, translates terminal and ACP activity into
application events, updates feature-owned state, executes explicit effects, and
renders the result with Ratatui.

## Architecture

The binary entry point is intentionally small: `src/main.rs` installs Tokio and
calls `qmtui::runtime::run()`. The library is organized around these layers:

| Area | Responsibility |
| --- | --- |
| `src/domain/` | Semantic data shared by state and features; no terminal, UI, transport, channel, filesystem, or process dependencies. |
| `src/*_state.rs` | Feature-owned semantic state and reducer operations. `src/render_state.rs` separately owns layout metrics, render caches, and Ratatui primitives. |
| `src/features/*/input/` | Feature input interpretation. These modules may consume Crossterm key types but do not perform I/O or know about rendering and ACP transport. |
| `src/features/*/view/` | Feature rendering with Ratatui. Views receive state or narrow render inputs and do not know about ACP transport. |
| `src/application.rs` | The `AppEvent`, `Effect`, and `update` boundary for application transitions. |
| `src/handlers.rs` | Root input routing and cross-feature composition over the feature input modules. |
| `src/acp/` | ACP connections, transport, protocol translation, replay, and command dispatch. This subsystem emits application-facing ACP events and has no UI dependency. |
| `src/runtime/` | Process composition: endpoint selection, channels, terminal lifecycle, event scheduling, persistence, clipboard/editor work, and effect execution. |
| `src/ui/mod.rs` | Root screen and popup composition through `ui::draw`; detailed rendering remains in feature view modules. |
| `src/app.rs` | Crate-local composition root joining the twelve feature/state owners plus `should_quit`. |

`App` fields are `pub(crate)` deliberately. Routing, persistence, rendering,
and runtime composition need to combine multiple owners, while public getters
for every field would broaden rather than improve the internal boundary.
Reducers are pure with respect to external side effects: they update in-memory
state and return `Effect` values for the runtime to execute.

## Event And Effect Flow

The application follows one production event path:

```text
Crossterm key/mouse ACP connection runtime/connection/supervisor
| | |
+-----------------------+---------------------------+
v
runtime::event_loop -> AppEvent
|
v
application::update
/ \
handlers + feature ACP/state reducers
input modules and coordination
\ /
state changes
+ Vec<Effect>
|
v
runtime::EffectExecutor
command channel / persistence / clipboard / editor / terminal / quit
|
RuntimeEvent feedback
|
application::update
|
v
ui::draw -> feature views -> RenderState
```

`runtime::event_loop::run_loop` schedules terminal, ACP, connection, supervisor,
and tick events without letting a permanently ready source starve the others.
It draws the current state, sends each event through `application::update`, and
passes returned effects to `runtime::EffectExecutor::execute`. Runtime work that
needs application feedback is converted to `RuntimeEvent` and re-enters the
same update path rather than mutating state from a background adapter.

ACP input follows the same boundary. `acp::inbound` decodes WebSocket or shared
connection input, `acp::notification::translate` converts SDK notifications to
application-facing updates, and `acp::notification::apply` handles replay and
stream ordering before emitting `AcpAppEvent`. The runtime event loop wraps that
as `AppEvent::Acp`; ACP code never renders or imports `crate::ui`.

## Boundary Rules

The architectural guidelines are:

- Domain modules stay free of UI/terminal crates, Tokio synchronization,
filesystem/process access, ACP transport, and protocol DTO imports.
- Top-level semantic `*_state.rs` modules stay free of Ratatui, Crossterm,
channels, I/O, process spawning, and ACP transport.
- Feature input may use Crossterm but not Ratatui, Tokio synchronization, I/O,
process spawning, or ACP transport. Feature views and root UI may use Ratatui
but not ACP transport.
- ACP may use Tokio, JSON-RPC, SDK, and transport types, but not `crate::ui`.
- Removed migration surfaces stay removed: legacy server-message symbols,
diagnostic forwarding methods on `App`, root UI temporary exports, broad
`use crate::handlers::*`, and crate-wide dead-code suppression.
- `src/main.rs` remains a thin call to `qmtui::runtime::run()`, and the twelve
`App` owners plus `should_quit` remain `pub(crate)`.

Intentional exceptions are narrow and documented:

- `src/render_state.rs`, feature views, and `src/ui/` may use Ratatui because
they own render caches, layout primitives, or rendering.
- Feature input may use Crossterm key types. Test modules may use `use super::*`.
- ACP owns its Tokio, JSON-RPC, SDK, and transport dependencies. Runtime,
configuration, session, and persistence adapters may own channels, files,
subprocesses, terminal operations, and other side effects.
- `src/themes_gen.rs` retains its generated crate-level `allow(dead_code)`;
targeted item-level allowances remain valid for retained decoded fields.
- The `cfg(test)` App-to-render adapters in `src/features/chat/view/mod.rs` are
retained for root cache/session integration tests.
- `TuiConfig::delegate_models` remains a read-only migration field, and hidden
`--acp-websocket` remains a compatibility alias for `--ws`.

## Testing

Tests live with the code that owns the contract. Domain and state modules test
semantic transitions; feature input modules test key-to-intent behavior; feature
view modules use Ratatui test backends for layout and rendering; ACP modules test
translation, command, replay, JSON-RPC, and transport contracts; and application,
handler, runtime, and root UI tests cover composition and event/effect ordering.

Run the same primary gates used by CI:

```sh
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo build --all-targets --all-features
cargo test --all-targets --all-features
```

Deterministic tests do not replace environmental validation. Live ACP/backend
behavior, an external compatible WebSocket service, subprocess stdio framing,
real terminal interaction, system clipboard integration, and complete
interactive UI smoke require suitable external services or a manual environment
and are not claimed by the automated suite.
32 changes: 17 additions & 15 deletions src/acp_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,8 @@ impl crate::app::App {
}
let effects = self.apply_auth_action(AuthAction::ClearUiNotice);
debug_assert!(effects.is_empty());
self.set_status(LogLevel::Info, "connection", "connected");
self.diagnostics
.set_status(LogLevel::Info, "connection", "connected");
vec![]
}
AcpAppEvent::AgentMode { mode } => {
Expand Down Expand Up @@ -413,7 +414,7 @@ impl crate::app::App {
vec![]
}
AcpAppEvent::ControlCapabilitiesUnavailable(message) => {
self.push_log(
self.diagnostics.push_log(
LogLevel::Warn,
"capabilities",
format!("capabilities unavailable: {message}"),
Expand Down Expand Up @@ -470,7 +471,7 @@ impl crate::app::App {
updates,
} => {
let updates = normalize_replay_updates(updates);
self.push_log(
self.diagnostics.push_log(
LogLevel::Info,
"session",
format!("session replay: {} update(s)", updates.len()),
Expand Down Expand Up @@ -527,7 +528,7 @@ impl crate::app::App {
self.apply_auth_action(AuthAction::OAuthResult(result))
}
AcpAppEvent::InfoLog { target, message } => {
self.push_log(LogLevel::Info, target, message);
self.diagnostics.push_log(LogLevel::Info, target, message);
vec![]
}
AcpAppEvent::Error { message } => {
Expand All @@ -545,7 +546,8 @@ impl crate::app::App {
effects,
} = self.auth.reduce(action);
for diagnostic in diagnostics {
self.push_log(diagnostic.level, "auth", diagnostic.message);
self.diagnostics
.push_log(diagnostic.level, "auth", diagnostic.message);
}
effects
}
Expand All @@ -561,12 +563,12 @@ impl crate::app::App {
level,
target,
message,
} => self.push_log(level, target, message),
} => self.diagnostics.push_log(level, target, message),
ModelCoordination::Status {
level,
target,
message,
} => self.set_status(level, target, message),
} => self.diagnostics.set_status(level, target, message),
ModelCoordination::SetContextLimit(limit) => self.chat.context_limit = limit,
}
}
Expand Down Expand Up @@ -641,7 +643,7 @@ impl crate::app::App {
level,
target,
message,
} => self.set_status(level, target, message),
} => self.diagnostics.set_status(level, target, message),
SessionCoordination::SynchronizeProfileCommands {
session_id,
root_only,
Expand Down Expand Up @@ -739,7 +741,7 @@ impl crate::app::App {
level,
target,
message,
} => self.set_status(level, target, message),
} => self.diagnostics.set_status(level, target, message),
HistoryCoordination::ClosePopup => self.navigation.popup = Popup::None,
HistoryCoordination::ReloadActiveSession => {
if let Some(session_id) = self.sessions.session_id.clone() {
Expand Down Expand Up @@ -837,12 +839,12 @@ impl crate::app::App {
level,
target,
message,
} => self.push_log(level, target, message),
} => self.diagnostics.push_log(level, target, message),
ChatCoordination::Status {
level,
target,
message,
} => self.set_status(level, target, message),
} => self.diagnostics.set_status(level, target, message),
ChatCoordination::RefreshTransientStatus => self.refresh_transient_status(),
}
}
Expand Down Expand Up @@ -1096,7 +1098,7 @@ impl crate::app::App {
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or("none");
self.push_log(
self.diagnostics.push_log(
LogLevel::Info,
"capabilities",
format!(
Expand Down Expand Up @@ -1125,7 +1127,7 @@ impl crate::app::App {
.get("models")
.and_then(Value::as_bool)
.unwrap_or(false);
self.push_log(
self.diagnostics.push_log(
LogLevel::Debug,
"capabilities",
format!(
Expand All @@ -1148,7 +1150,7 @@ impl crate::app::App {
.map(String::as_str)
.filter(|m| m.starts_with("querymt/"))
.collect();
self.push_log(
self.diagnostics.push_log(
LogLevel::Debug,
"capabilities",
format!(
Expand All @@ -1164,7 +1166,7 @@ impl crate::app::App {
} else {
String::new()
};
self.push_log(
self.diagnostics.push_log(
LogLevel::Debug,
"capabilities",
format!("querymt methods: {}{suffix}", preview.join(", ")),
Expand Down
Loading