diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..ee0e82a8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,34 @@ +{ + "name": "DeepSeek TUI", + "dockerFile": "../Dockerfile", + "build": { + "args": { + "RUST_VERSION": "1.85" + } + }, + "customizations": { + "vscode": { + "extensions": [ + "rust-lang.rust-analyzer", + "tamasfe.even-better-toml", + "vadimcn.vscode-lldb" + ], + "settings": { + "rust-analyzer.cargo.features": "all", + "editor.formatOnSave": true + } + } + }, + "remoteEnv": { + "DEEPSEEK_API_KEY": "${localEnv:DEEPSEEK_API_KEY}" + }, + "mounts": [ + "source=${localEnv:HOME}/.deepseek,target=/home/deepseek/.deepseek,type=bind,consistency=cached" + ], + "features": { + "ghcr.io/devcontainers/features/rust:1": {}, + "ghcr.io/devcontainers/features/git:1": {} + }, + "postCreateCommand": "cargo build", + "remoteUser": "deepseek" +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 818047b1..490f5ca5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,8 +115,50 @@ jobs: with: name: ${{ matrix.artifact_name }} path: ${{ matrix.artifact_name }} + docker: + needs: build + if: ${{ !cancelled() && needs.build.result == 'success' }} + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern=v{{major}} + type=ref,event=tag + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + release: needs: build + if: ${{ !cancelled() && needs.build.result == 'success' }} runs-on: ubuntu-latest permissions: contents: write diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..cf24cd90 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# syntax=docker/dockerfile:1 +# DeepSeek-TUI multi-arch Docker image (#501) +# +# Build: docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui:latest . +# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v ~/.deepseek:/home/deepseek/.deepseek deepseek-tui +# +# The image ships both binaries (deepseek dispatcher + deepseek-tui runtime) +# in a minimal runtime layer. No MCP servers or heavy toolchains are included +# — keep it slim. + +ARG RUST_VERSION=1.85 + +# ── Stage 1: Build ──────────────────────────────────────────────────── +FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder +ARG TARGETPLATFORM +ARG BUILDPLATFORM + +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config libdbus-1-dev \ + && rm -rf /var/lib/apt/lists/* + +# Translate Docker platform into Rust target triple. +# linux/amd64 → x86_64-unknown-linux-gnu +# linux/arm64 → aarch64-unknown-linux-gnu +RUN case "${TARGETPLATFORM}" in \ + linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \ + linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \ + *) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \ + esac + +RUN rustup target add "$(cat /rust-target)" + +WORKDIR /build +COPY . . + +# Build both binaries for the target platform. --locked ensures +# reproducible builds from the committed lockfile. +RUN --mount=type=cache,target=/build/target \ + --mount=type=cache,target=/usr/local/cargo/registry \ + cargo build --release --locked --target "$(cat /rust-target)" \ + && mkdir -p /out \ + && cp target/$(cat /rust-target)/release/deepseek /out/ \ + && cp target/$(cat /rust-target)/release/deepseek-tui /out/ + +# ── Stage 2: Runtime ────────────────────────────────────────────────── +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libdbus-1-3 \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user. +RUN useradd --create-home --shell /bin/bash deepseek +USER deepseek +WORKDIR /home/deepseek + +COPY --from=builder --chown=deepseek:deepseek /out/deepseek /usr/local/bin/deepseek +COPY --from=builder --chown=deepseek:deepseek /out/deepseek-tui /usr/local/bin/deepseek-tui + +# The dispatcher expects to find its companion binary next to it. +# Both are in /usr/local/bin — no further path setup needed. + +ENV DEEPSEEK_API_KEY="" +ENV DEEPSEEK_NO_COLOR="" + +ENTRYPOINT ["deepseek"] +CMD [] diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 9bf92ab0..5d9a14fe 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -189,6 +189,7 @@ pub enum SidebarFocusValue { Todos, Tasks, Agents, + Context, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -666,6 +667,7 @@ impl SidebarFocusValue { Self::Todos => "todos", Self::Tasks => "tasks", Self::Agents => "agents", + Self::Context => "context", } } } @@ -754,6 +756,7 @@ impl From<&str> for SidebarFocusValue { SidebarFocus::Todos => Self::Todos, SidebarFocus::Tasks => Self::Tasks, SidebarFocus::Agents => Self::Agents, + SidebarFocus::Context => Self::Context, } } } diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 0282fa40..c84b0f04 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -45,8 +45,11 @@ pub struct Settings { pub default_mode: String, /// Sidebar width as percentage of terminal width pub sidebar_width_percent: u16, - /// Sidebar focus mode: auto, plan, todos, tasks, agents + /// Sidebar focus mode: auto, plan, todos, tasks, agents, context pub sidebar_focus: String, + /// Enable the session-context panel (#504). Shows working set, tokens, + /// cost, MCP/LSP status, cycle count, and memory info. + pub context_panel: bool, /// Maximum number of input history entries to save pub max_input_history: usize, /// Default model to use @@ -71,6 +74,7 @@ impl Default for Settings { default_mode: "agent".to_string(), sidebar_width_percent: 28, sidebar_focus: "auto".to_string(), + context_panel: false, max_input_history: 100, default_model: None, } @@ -423,6 +427,7 @@ fn normalize_sidebar_focus(value: &str) -> &str { "todos" => "todos", "tasks" => "tasks", "agents" | "subagents" | "sub-agents" => "agents", + "context" | "session" => "context", _ => "auto", } } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 477a8afd..c90f2701 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -169,6 +169,7 @@ pub enum SidebarFocus { Todos, Tasks, Agents, + Context, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -215,6 +216,7 @@ impl SidebarFocus { "todos" => Self::Todos, "tasks" => Self::Tasks, "agents" | "subagents" | "sub-agents" => Self::Agents, + "context" | "session" => Self::Context, _ => Self::Auto, } } @@ -228,6 +230,7 @@ impl SidebarFocus { Self::Todos => "todos", Self::Tasks => "tasks", Self::Agents => "agents", + Self::Context => "context", } } } @@ -597,6 +600,8 @@ pub struct App { pub transcript_spacing: TranscriptSpacing, pub sidebar_width_percent: u16, pub sidebar_focus: SidebarFocus, + /// Whether the session-context panel is enabled (#504). + pub context_panel: bool, /// File-tree pane state. `None` when hidden; `Some` when visible. pub file_tree: Option, #[allow(dead_code)] @@ -1118,6 +1123,7 @@ impl App { transcript_spacing, sidebar_width_percent, sidebar_focus, + context_panel: settings.context_panel, file_tree: None, compact_threshold, max_input_history, @@ -2834,14 +2840,13 @@ impl App { self.paste_burst.clear_after_explicit_paste(); return None; } - let mut input = self.input.clone(); - if char_count(&input) > MAX_SUBMITTED_INPUT_CHARS { - input = input.chars().take(MAX_SUBMITTED_INPUT_CHARS).collect(); - self.status_message = Some(format!( - "Input truncated to {} characters for safety", - MAX_SUBMITTED_INPUT_CHARS - )); + // When the input exceeds the safety cap, consolidate it into a + // workspace paste file and replace it with an @mention so the + // model can read the full content at turn time (#553). + if char_count(&self.input) > MAX_SUBMITTED_INPUT_CHARS { + self.consolidate_large_input(); } + let input = self.input.clone(); if !input.starts_with('/') { self.input_history.push(input.clone()); if self.max_input_history == 0 { @@ -2861,6 +2866,61 @@ impl App { Some(input) } + /// When the composer input exceeds [`MAX_SUBMITTED_INPUT_CHARS`], write + /// the full content to a timestamped paste file under + /// `.deepseek/pastes/` and replace `self.input` with an `@`-mention + /// pointing at it so the model can read the full content via the + /// normal file-mention resolution path (#553). + fn consolidate_large_input(&mut self) { + let full_input = std::mem::take(&mut self.input); + self.cursor_position = 0; + + let now = chrono::Local::now(); + let suffix = uuid::Uuid::new_v4().to_string()[..8].to_string(); + let filename = format!("paste-{}-{}.md", now.format("%Y-%m-%d-%H%M%S"), suffix); + let rel_path = format!(".deepseek/pastes/{filename}"); + + let pastes_dir = self.workspace.join(".deepseek/pastes"); + if let Err(e) = std::fs::create_dir_all(&pastes_dir) { + // Fallback: keep a truncated version so we don't lose the + // user's input entirely when the filesystem is unhappy. + self.input = full_input + .chars() + .take(MAX_SUBMITTED_INPUT_CHARS) + .collect(); + self.cursor_position = char_count(&self.input); + self.push_status_toast( + format!("Failed to create paste directory: {e}"), + StatusToastLevel::Error, + Some(8_000), + ); + return; + } + + let file_path = self.workspace.join(&rel_path); + if let Err(e) = std::fs::write(&file_path, &full_input) { + self.input = full_input + .chars() + .take(MAX_SUBMITTED_INPUT_CHARS) + .collect(); + self.cursor_position = char_count(&self.input); + self.push_status_toast( + format!("Failed to write paste file: {e}"), + StatusToastLevel::Error, + Some(8_000), + ); + return; + } + + self.input = format!("@{rel_path}"); + self.cursor_position = char_count(&self.input); + self.push_status_toast( + "Large paste consolidated — sent as @mention", + StatusToastLevel::Info, + Some(5_000), + ); + } + pub fn queue_message(&mut self, message: QueuedMessage) { self.queued_messages.push_back(message); } @@ -3231,18 +3291,46 @@ mod tests { } #[test] - fn submit_input_truncates_oversized_payloads() { - let mut app = App::new(test_options(false), &Config::default()); - app.input = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128); + fn submit_input_consolidates_oversized_input_into_paste_file() { + let tmp = tempfile::TempDir::new().expect("tempdir"); + let mut opts = test_options(false); + opts.workspace = tmp.path().to_path_buf(); + let mut app = App::new(opts, &Config::default()); + let full_content = "x".repeat(MAX_SUBMITTED_INPUT_CHARS + 128); + app.input = full_content.clone(); app.cursor_position = app.input.chars().count(); let submitted = app.submit_input().expect("expected submitted input"); - assert_eq!(submitted.chars().count(), MAX_SUBMITTED_INPUT_CHARS); + + // The submitted text should be the @mention, not the truncated + // original (#553). assert!( - app.status_message - .as_ref() - .is_some_and(|msg| msg.contains("Input truncated")) + submitted.starts_with("@.deepseek/pastes/paste-"), + "expected @mention, got: {submitted}" ); + assert!( + submitted.ends_with(".md"), + "expected .md extension, got: {submitted}" + ); + + // The paste file must exist on disk with the full original content. + let rel_path = &submitted[1..]; // strip leading '@' + let abs_path = tmp.path().join(rel_path); + assert!(abs_path.is_file(), "paste file must exist at {abs_path:?}"); + let written = std::fs::read_to_string(&abs_path).expect("read paste file"); + assert_eq!(written, full_content); + + // A status toast should have been pushed. + assert!( + app.status_toasts + .iter() + .any(|toast| toast.text.contains("consolidated")), + "expected consolidation toast, got: {:?}", + app.status_toasts.iter().map(|t| &t.text).collect::>() + ); + + // The composer must be clear after submit. + assert!(app.input.is_empty()); } #[test] diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index d07e399f..2d9814cf 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -42,6 +42,7 @@ pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { SidebarFocus::Todos => render_sidebar_todos(f, area, app), SidebarFocus::Tasks => render_sidebar_tasks(f, area, app), SidebarFocus::Agents => render_sidebar_subagents(f, area, app), + SidebarFocus::Context => render_context_panel(f, area, app), } } @@ -57,6 +58,7 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) { Todos, Tasks, Agents, + Context, } let todos_empty = app @@ -70,7 +72,7 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) { && active_fanout_counts(app).is_none() && !foreground_rlm_running(app); - let mut visible: Vec = Vec::with_capacity(4); + let mut visible: Vec = Vec::with_capacity(5); visible.push(Panel::Plan); if !todos_empty { visible.push(Panel::Todos); @@ -81,6 +83,9 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) { if !agents_empty { visible.push(Panel::Agents); } + if app.context_panel { + visible.push(Panel::Context); + } let constraints: Vec = match visible.len() { 1 => vec![Constraint::Min(0)], @@ -90,10 +95,17 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) { Constraint::Percentage(33), Constraint::Min(0), ], + 4 => vec![ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Min(6), + ], _ => vec![ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), Constraint::Min(6), ], }; @@ -109,6 +121,7 @@ fn render_sidebar_auto(f: &mut Frame, area: Rect, app: &App) { Panel::Todos => render_sidebar_todos(f, *rect, app), Panel::Tasks => render_sidebar_tasks(f, *rect, app), Panel::Agents => render_sidebar_subagents(f, *rect, app), + Panel::Context => render_context_panel(f, *rect, app), } } } @@ -599,6 +612,123 @@ pub fn subagent_navigator_lines( lines } +/// Session-context panel (#504) — consolidated session state overview. +/// +/// Surfaces at-a-glance: working set, token usage / context %, running +/// cost, MCP server count, LSP toggle state, cycle count, and memory +/// file size + mtime. Each section is a compact one-liner so the panel +/// reads as a dashboard rather than a scrolling list. +fn render_context_panel(f: &mut Frame, area: Rect, app: &App) { + if area.height < 3 { + return; + } + + let content_width = area.width.saturating_sub(4) as usize; + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); + + // ── Working set ────────────────────────────────────────────── + let ws_name = app + .workspace + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("(root)") + .to_string(); + lines.push(Line::from(vec![ + Span::styled( + truncate_line_to_width(&ws_name, content_width.max(1)), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ), + Span::styled( + format!( + " {}", + app.workspace_context.as_deref().unwrap_or("") + ), + Style::default().fg(palette::TEXT_DIM), + ), + ])); + + // ── Token usage ────────────────────────────────────────────── + let total_tokens = app.session.total_conversation_tokens; + let window = crate::models::context_window_for_model(&app.model).unwrap_or(1_048_576); + let pct = if window > 0 { + ((total_tokens as f64 / window as f64) * 100.0).clamp(0.0, 100.0) + } else { + 0.0 + }; + let bar_width = content_width.min(20); + let filled = ((pct / 100.0) * bar_width as f64) as usize; + let bar = format!( + "[{}{}] {:.0}%", + "█".repeat(filled), + "░".repeat(bar_width.saturating_sub(filled)), + pct + ); + lines.push(Line::from(Span::styled( + format!( + "context: {}/{} tokens {}", + total_tokens, + window, + truncate_line_to_width(&bar, content_width.saturating_sub(32).max(8)) + ), + Style::default().fg(palette::TEXT_MUTED), + ))); + + // ── Session cost ───────────────────────────────────────────── + let total_cost = app.session.session_cost + app.session.subagent_cost; + lines.push(Line::from(Span::styled( + format!("cost: ${total_cost:.4} (session ${:.4} + agents ${:.4})", + app.session.session_cost, app.session.subagent_cost), + Style::default().fg(palette::TEXT_MUTED), + ))); + + // ── MCP servers ────────────────────────────────────────────── + if app.mcp_configured_count > 0 { + let restart_hint = if app.mcp_restart_required { " (restart needed)" } else { "" }; + lines.push(Line::from(Span::styled( + format!("mcp: {} server(s){}", app.mcp_configured_count, restart_hint), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + + // ── LSP ────────────────────────────────────────────────────── + let lsp_label = if app.lsp_enabled { "on" } else { "off" }; + lines.push(Line::from(Span::styled( + format!("lsp: {}", lsp_label), + Style::default().fg(palette::TEXT_MUTED), + ))); + + // ── Cycles ─────────────────────────────────────────────────── + if app.cycle_count > 0 { + lines.push(Line::from(Span::styled( + format!("cycles: {} crossed, {} briefing(s)", + app.cycle_count, app.cycle_briefings.len()), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + + // ── Memory ─────────────────────────────────────────────────── + if app.use_memory { + let size_hint = std::fs::metadata(&app.memory_path) + .map(|m| m.len()) + .map(|bytes| { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{} B", bytes) + } + }) + .unwrap_or_else(|_| "—".to_string()); + lines.push(Line::from(Span::styled( + format!("memory: {} ({})", app.memory_path.display(), size_hint), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + + render_sidebar_section(f, area, "Session", lines); +} + fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec>) { if area.width < 4 || area.height < 3 { // Clear stale cells before bailing out (#400). diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index b40b599b..57026836 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1138,6 +1138,7 @@ async fn run_event_loop( EngineEvent::AgentList { agents } => { let mut sorted = agents.clone(); sort_subagents_in_place(&mut sorted); + sorted.retain(|a| !a.from_prior_session); app.subagent_cache = sorted.clone(); reconcile_subagent_activity_state(app); if app.view_stack.update_subagents(&sorted) { @@ -1990,6 +1991,11 @@ async fn run_event_loop( app.status_message = Some("Sidebar focus: agents".to_string()); continue; } + KeyCode::Char('%') if key.modifiers.contains(KeyModifiers::ALT) => { + app.set_sidebar_focus(SidebarFocus::Context); + app.status_message = Some("Sidebar focus: context".to_string()); + continue; + } KeyCode::Char(')') if key.modifiers.contains(KeyModifiers::ALT) => { app.set_sidebar_focus(SidebarFocus::Auto); app.status_message = Some("Sidebar focus: auto".to_string()); @@ -2997,7 +3003,7 @@ fn queue_current_draft_for_next_turn(app: &mut App) -> bool { }; app.queue_message(queued); app.status_message = Some(format!( - "Queued follow-up for next turn ({} queued) - /queue to review", + "{} queued — ↑ to edit, /queue list", app.queued_message_count() )); true @@ -4198,7 +4204,7 @@ async fn queue_follow_up(app: &mut App, message: QueuedMessage) -> Result<()> { let display = message.display.clone(); app.queue_message(message); app.status_message = Some(format!( - "Queued follow-up: {} ({} queued) - /queue to review", + "Queued: {} ({} total) — ↑ to edit", display, app.queued_message_count() )); @@ -4217,11 +4223,11 @@ async fn submit_or_steer_message( app.queue_message(message); if app.offline_mode { app.status_message = Some(format!( - "Offline mode: queued {count} message(s) - /queue to review" + "Offline: {count} queued — ↑ to edit, /queue list" )); } else { app.status_message = Some(format!( - "Queued for next turn: {count} message(s) - Ctrl+Enter to steer, /queue to review" + "{count} queued — ↑ to edit, /queue list" )); } Ok(()) @@ -4231,7 +4237,7 @@ async fn submit_or_steer_message( if let Err(err) = steer_user_message(app, engine_handle, message.clone()).await { app.queue_message(message); app.status_message = Some(format!( - "Steer failed ({err}); queued {} message(s) - /queue to view/edit", + "Steer failed ({err}); {} queued — ↑ to edit, /queue list", app.queued_message_count() )); } else { @@ -6441,10 +6447,18 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { MouseEventKind::ScrollUp => { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Up); app.viewport.pending_scroll_delta += update.delta_lines; + if update.delta_lines != 0 { + app.user_scrolled_during_stream = true; + app.needs_redraw = true; + } } MouseEventKind::ScrollDown => { let update = app.viewport.mouse_scroll.on_scroll(ScrollDirection::Down); app.viewport.pending_scroll_delta += update.delta_lines; + if update.delta_lines != 0 { + app.user_scrolled_during_stream = true; + app.needs_redraw = true; + } } MouseEventKind::Down(MouseButton::Left) => { if let Some(point) = selection_point_from_mouse(app, mouse) { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b9955b67..8394e9c3 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1468,7 +1468,7 @@ async fn dismissed_plan_prompt_leaves_non_numeric_input_for_normal_send_path() { ); assert_eq!( app.status_message.as_deref(), - Some("Offline mode: queued 1 message(s) - /queue to review") + Some("Offline: 1 queued — ↑ to edit, /queue list") ); } @@ -2839,7 +2839,7 @@ fn tab_queues_running_turn_draft_for_next_turn() { assert!( app.status_message .as_deref() - .is_some_and(|msg| msg.contains("Queued follow-up")) + .is_some_and(|msg| msg.contains("queued — ↑")) ); } diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 00000000..dfe38762 --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,75 @@ +# Docker + +DeepSeek TUI ships an official multi-arch Docker image (amd64 + arm64) on +[GitHub Container Registry](https://github.com/Hmbown/DeepSeek-TUI/pkgs/container/deepseek-tui). + +## Quick start + +```bash +docker run --rm -it \ + -e DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" \ + -v ~/.deepseek:/home/deepseek/.deepseek \ + ghcr.io/hmbown/deepseek-tui:latest +``` + +Images are published to GitHub Container Registry (GHCR) only. Docker Hub +publishing is not currently configured — add a `docker/login-action` step +with Hub credentials to the release workflow if needed. + +## Environment variables + +| Variable | Required | Description | +|-----------------------|----------|--------------------------------------------------| +| `DEEPSEEK_API_KEY` | yes | DeepSeek API key | +| `DEEPSEEK_BASE_URL` | no | Custom API base URL (e.g. `https://api.deepseek.com`) | +| `DEEPSEEK_NO_COLOR` | no | Set to `1` to disable terminal colour output | + +## Volumes + +Mount `~/.deepseek` to persist sessions, config, skills, memory, and the offline queue +across container restarts: + +```bash +-v ~/.deepseek:/home/deepseek/.deepseek +``` + +Without this mount the container starts fresh each time. + +## Non-interactive / pipeline usage + +When stdin is not a TTY, `deepseek` drops to the dispatcher's one-shot mode +(`deepseek -c "…"`). Pipe a prompt on stdin: + +```bash +echo "Explain the Cargo.toml in structured English." | \ + docker run --rm -i -e DEEPSEEK_API_KEY ghcr.io/hmbown/deepseek-tui:latest +``` + +## Building locally + +```bash +# Single platform (your host architecture) +docker build -t deepseek-tui . + +# Multi-platform (requires a builder with emulation) +docker buildx create --use +docker buildx build --platform linux/amd64,linux/arm64 -t deepseek-tui . +``` + +## Devcontainer + +The repository includes a [`.devcontainer/devcontainer.json`](../.devcontainer/devcontainer.json) +configuration for VS Code / GitHub Codespaces. It pre-installs the Rust toolchain, +rust-analyzer, and the `deepseek` binary. Open the repo in a devcontainer to get a +ready-to-use development environment. + +## Tags + +| Tag | Meaning | +|------------|--------------------------| +| `latest` | Latest stable release | +| `v0` | Latest v0.x release | +| `0.8.9` | Specific release version | + +Docker images are built and pushed automatically when a release tag is pushed +(see [release.yml](../.github/workflows/release.yml)). diff --git a/docs/RUNTIME_API.md b/docs/RUNTIME_API.md index d767390f..91e54af5 100644 --- a/docs/RUNTIME_API.md +++ b/docs/RUNTIME_API.md @@ -1,172 +1,196 @@ -# Runtime API (HTTP/SSE) +# Runtime API & Integration Contract -DeepSeek TUI can expose a local runtime API for external clients: +DeepSeek TUI exposes a local runtime API through `deepseek serve --http` and +machine-readable health via `deepseek doctor --json`. This document is the +stable integration contract for native macOS workbench applications (and other +local supervisors) that embed the DeepSeek engine without screen-scraping +terminal output. -```bash -deepseek serve --http --host 127.0.0.1 --port 7878 --workers 2 +## Architecture + +``` +macOS workbench (or any local supervisor) + │ + ├─ deepseek doctor --json → machine-readable health & capability + ├─ deepseek serve --http → HTTP/SSE runtime API + ├─ deepseek serve --mcp → MCP stdio server + └─ deepseek [args] → interactive TUI session ``` -Defaults: -- bind: `127.0.0.1:7878` -- workers: `2` (clamped to `1..8`) +The engine runs as a local-only process. All APIs bind to `localhost` by +default. No hosted relay, no provider-token custody, no secret leakage. -Implementation note: -- The current production runtime lives in `crates/tui` (`runtime_api.rs`, `runtime_threads.rs`, `task_manager.rs`). -- Workspace crate extraction is in progress, but external behavior should be read from the `crates/tui` implementation today. +## Capability endpoint: `deepseek doctor --json` -## Security Model (Local-First) +Returns a JSON object describing the current installation's readiness state. +Suitable for health-check polling from a macOS workbench. -- The server is designed for trusted local use. -- There is no built-in auth, user isolation, or TLS termination. -- Do not expose this API directly to untrusted networks. -- If remote access is required, place it behind your own authenticated reverse proxy/VPN. +```bash +deepseek doctor --json +``` -## Runtime Data Model +### Response schema (key fields) -The runtime uses a durable Thread/Turn/Item lifecycle. +| Field | Type | Description | +|---|---|---| +| `version` | string | Installed version (e.g. `"0.8.9"`) | +| `config_path` | string | Resolved config file path | +| `config_present` | bool | Whether the config file exists | +| `workspace` | string | Default workspace directory | +| `api_key.source` | string | `env`, `config`, or `missing` | +| `base_url` | string | API base URL | +| `default_text_model` | string | Default model | +| `memory.enabled` | bool | Whether the memory feature is on | +| `memory.path` | string | Path to memory file | +| `memory.file_present` | bool | Whether memory file exists | +| `mcp.config_path` | string | MCP config file path | +| `mcp.present` | bool | Whether MCP config exists | +| `mcp.servers` | array | Per-server health: `{name, enabled, status, detail}` | +| `skills.selected` | string | Resolved skills directory | +| `skills.global.path` / `.present` / `.count` | — | Global skills dir | +| `skills.agents.path` / `.present` / `.count` | — | `.agents/skills/` dir | +| `skills.local.path` / `.present` / `.count` | — | `skills/` dir | +| `skills.opencode.path` / `.present` / `.count` | — | `.opencode/skills/` dir | +| `skills.claude.path` / `.present` / `.count` | — | `.claude/skills/` dir | +| `tools.path` / `.present` / `.count` | — | Global tools directory | +| `plugins.path` / `.present` / `.count` | — | Global plugins directory | +| `sandbox.available` | bool | Whether sandbox is supported on this OS | +| `sandbox.kind` | string or null | Sandbox kind (e.g. `"macos_seatbelt"`) | +| `storage.spillover.path` / `.present` / `.count` | — | Tool output spillover dir | +| `storage.stash.path` / `.present` / `.count` | — | Composer stash | -- `ThreadRecord` - - `id`, `created_at`, `updated_at` - - `model`, `workspace`, `mode` - - `task_id` (optional durable task link) - - `coherence_state`: `healthy|getting_crowded|refreshing_context|verifying_recent_work|resetting_plan` - - `system_prompt` (optional text) - - `latest_turn_id`, `latest_response_bookmark`, `archived` -- `TurnRecord` - - `id`, `thread_id` - - `status`: `queued|in_progress|completed|failed|interrupted|canceled` - - timestamps, duration, usage, error summary -- `TurnItemRecord` - - `id`, `turn_id` - - `kind`: `user_message|agent_message|tool_call|file_change|command_execution|context_compaction|status|error` - - lifecycle `status`: `queued|in_progress|completed|failed|interrupted|canceled` - - `metadata` (optional tool result metadata; used for task checklist/gate/artifact updates) +### Example -The event log is append-only with global monotonic `seq` for replay/resume. +```json +{ + "version": "0.8.9", + "config_path": "/Users/you/.deepseek/config.toml", + "config_present": true, + "workspace": "/Users/you/projects/deepseek-tui", + "api_key": { + "source": "env" + }, + "base_url": "https://api.deepseek.com", + "default_text_model": "deepseek-v4-pro", + "memory": { + "enabled": false, + "path": "/Users/you/.deepseek/memory.md", + "file_present": true + }, + "mcp": { + "config_path": "/Users/you/.deepseek/mcp.json", + "present": true, + "servers": [ + {"name": "filesystem", "enabled": true, "status": "ok", "detail": "ready"} + ] + }, + "sandbox": { + "available": true, + "kind": "macos_seatbelt" + } +} +``` -Session resume note: -- Saved session `system_prompt` currently round-trips as plain text. Structured `SystemPrompt::Blocks` metadata is not preserved when resuming into runtime threads. +## HTTP/SSE runtime API: `deepseek serve --http` -Restart note: -- If the process restarts while a turn or item is `queued` or `in_progress`, the recovered record is marked `interrupted` with an `"Interrupted by process restart"` error instead of remaining stuck in a live state. +```bash +deepseek serve --http [--host 127.0.0.1] [--port 7878] [--workers 2] +``` -Approval note: -- `auto_approve` applies to the runtime approval bridge and the engine tool context. When enabled for a thread/turn/task, approval-required tools are auto-approved in the non-interactive runtime path, shell safety checks run in auto-approved mode, and spawned subagents inherit that effective setting for their own tool context. -- If omitted when creating a thread or starting `/v1/stream`, `auto_approve` defaults to `false`. +Defaults: host `127.0.0.1`, port `7878`, 2 workers (clamped 1–8). -## Endpoints +The server binds to `localhost` by default. Configuration is via CLI flags — +there is no `[app_server]` config section. -### Health and Session +### Endpoints +**Health** - `GET /health` + +**Sessions** (legacy session manager) - `GET /v1/sessions?limit=50&search=` - `GET /v1/sessions/{id}` - `DELETE /v1/sessions/{id}` - `POST /v1/sessions/{id}/resume-thread` -- `GET /v1/workspace/status` -- `GET /v1/skills` -- `GET /v1/apps/mcp/servers` -- `GET /v1/apps/mcp/tools?server=` -Resume session request body (all fields optional): - -```json -{ - "model": "deepseek-v4-pro", - "mode": "agent" -} -``` - -Resume session response: - -```json -{ - "thread_id": "thr_1234abcd", - "session_id": "sess_5678efgh", - "message_count": 24, - "summary": "Resumed session 'Refactor plan' (24 messages) into thread thr_1234abcd" -} -``` - -### Compatibility Stream (Single Turn) - -- `POST /v1/stream` - -Backwards-compatible one-shot SSE wrapper. Internally creates an archived runtime thread+turn. - -Request body: - -```json -{ - "prompt": "Summarize recent commits", - "model": "deepseek-v4-pro", - "mode": "agent", - "workspace": ".", - "allow_shell": false, - "trust_mode": false, - "auto_approve": true -} -``` - -Typical SSE events: -- `turn.started` -- `message.delta` -- `tool.started` -- `tool.progress` -- `tool.completed` -- `approval.required` -- `sandbox.denied` -- `status` -- `error` -- `turn.completed` -- `done` - -### Thread Lifecycle - -- `POST /v1/threads` +**Threads** (durable runtime data model) - `GET /v1/threads?limit=50&include_archived=false` - `GET /v1/threads/summary?limit=50&search=&include_archived=false` +- `POST /v1/threads` - `GET /v1/threads/{id}` - `PATCH /v1/threads/{id}` (currently supports `{ "archived": true|false }`) - `POST /v1/threads/{id}/resume` - `POST /v1/threads/{id}/fork` -Create thread request example: - -```json -{ - "model": "deepseek-v4-pro", - "workspace": ".", - "mode": "agent", - "allow_shell": false, - "trust_mode": false, - "auto_approve": true, - "archived": false, - "task_id": "task_1234abcd" -} -``` - -### Turn Lifecycle - +**Turns** (within a thread) - `POST /v1/threads/{id}/turns` - `POST /v1/threads/{id}/turns/{turn_id}/steer` - `POST /v1/threads/{id}/turns/{turn_id}/interrupt` -- `POST /v1/threads/{id}/compact` - -Notes: -- Only one active turn is allowed per thread (`409 Conflict` on overlap). -- `interrupt` returns quickly and marks `turn.interrupt_requested`. -- Terminal turn status becomes `interrupted` only after cleanup completes. -- Manual compaction is exposed as a turn with `context_compaction` item lifecycle events. -- Archiving/unarchiving threads updates persisted thread state and emits `thread.updated`. - -### Replayable Events +- `POST /v1/threads/{id}/compact` (manual compaction) +**Events** (SSE replay + live stream) - `GET /v1/threads/{id}/events?since_seq=` -Returns SSE replay backlog, then live events for that thread. +**Compatibility stream** (one-shot, backwards-compatible) +- `POST /v1/stream` -SSE payload shape: +**Tasks** (durable background work) +- `GET /v1/tasks` +- `POST /v1/tasks` +- `GET /v1/tasks/{id}` +- `POST /v1/tasks/{id}/cancel` + +**Automations** (scheduled recurring work) +- `GET /v1/automations` +- `POST /v1/automations` +- `GET /v1/automations/{id}` +- `PATCH /v1/automations/{id}` +- `DELETE /v1/automations/{id}` +- `POST /v1/automations/{id}/run` +- `POST /v1/automations/{id}/pause` +- `POST /v1/automations/{id}/resume` +- `GET /v1/automations/{id}/runs?limit=20` + +**Introspection** +- `GET /v1/workspace/status` +- `GET /v1/skills` +- `GET /v1/apps/mcp/servers` +- `GET /v1/apps/mcp/tools?server=` + +## Runtime data model + +The runtime uses a durable Thread/Turn/Item lifecycle. + +- **ThreadRecord** — `id`, `created_at`, `updated_at`, `model`, `workspace`, + `mode`, `task_id`, `coherence_state`, `system_prompt`, `latest_turn_id`, + `latest_response_bookmark`, `archived` +- **TurnRecord** — `id`, `thread_id`, `status` (`queued|in_progress|completed| + failed|interrupted|canceled`), timestamps, duration, usage, error summary +- **TurnItemRecord** — `id`, `turn_id`, `kind` (`user_message|agent_message| + tool_call|file_change|command_execution|context_compaction|status|error`), + lifecycle `status`, `metadata` + +Events are append-only with a global monotonic `seq` for replay/resume. + +### Restart semantics + +- If the process restarts while a turn or item is `queued` or `in_progress`, + the recovered record is marked `interrupted` with an `"Interrupted by + process restart"` error. +- Task execution performs its own recovery on top of the same persisted + thread/turn store. + +### Approval model + +- The `auto_approve` flag applies to the runtime approval bridge and engine + tool context. When enabled for a thread/turn/task, approval-required tools + are auto-approved in the non-interactive runtime path, shell safety checks + run in auto-approved mode, and spawned sub-agents inherit that setting. +- When omitted, `auto_approve` defaults to `false`. + +### SSE event stream + +The SSE event payload shape: ```json { @@ -183,95 +207,48 @@ SSE payload shape: } ``` -Common event names: -- `thread.started` -- `thread.forked` -- `turn.started` -- `turn.lifecycle` -- `turn.steered` -- `turn.interrupt_requested` -- `turn.completed` -- `item.started` -- `item.delta` -- `item.completed` -- `item.failed` -- `item.interrupted` -- `approval.required` -- `sandbox.denied` -- `coherence.state` +Common event names: `thread.started`, `thread.forked`, `turn.started`, +`turn.lifecycle`, `turn.steered`, `turn.interrupt_requested`, +`turn.completed`, `item.started`, `item.delta`, `item.completed`, +`item.failed`, `item.interrupted`, `approval.required`, `sandbox.denied`, +`coherence.state`. -Compaction visibility: -- auto compaction emits `item.started`/`item.completed` with item kind `context_compaction` and `auto=true` -- manual compaction emits the same with `auto=false` +## Security boundary -Coherence visibility: -- `coherence.state` is a machine-readable session-health signal derived from - existing capacity and compaction events. The payload includes `state`, - `label`, `description`, `reason`, and the updated `thread`. -- Normal clients should show the `label` or `description`, not internal - capacity scores or formulas. +- **Localhost only**. The server binds to `127.0.0.1` by default. Set + `--host 0.0.0.0` only when you have a reverse-proxy / VPN that + authenticates — there is no built-in auth, user isolation, or TLS. +- **No provider-token custody**. The server never returns the API key. The + `api_key.source` capability field reports `env`, `config`, or `missing` — + never the key itself. +- **No hosted relay**. The app-server is a local process under the user's + control. There is no cloud component. +- **Capability responses** never leak secrets, file contents, or session + message bodies. They report *metadata*: presence, counts, status flags. -### Background Tasks +## Session lifecycle (native UI supervision) -- `GET /v1/tasks` -- `POST /v1/tasks` -- `GET /v1/tasks/{id}` -- `POST /v1/tasks/{id}/cancel` +| Operation | Endpoint | +|---|---| +| List sessions | `GET /v1/sessions` | +| Get session | `GET /v1/sessions/{id}` | +| Delete session | `DELETE /v1/sessions/{id}` | +| Resume into thread | `POST /v1/sessions/{id}/resume-thread` | +| Create thread | `POST /v1/threads` | +| List threads | `GET /v1/threads` | +| Attach to events | `GET /v1/threads/{id}/events?since_seq=0` | +| Send message | `POST /v1/threads/{id}/turns` | +| Steer | `POST /v1/threads/{id}/turns/{turn_id}/steer` | +| Interrupt | `POST /v1/threads/{id}/turns/{turn_id}/interrupt` | +| Compact | `POST /v1/threads/{id}/compact` | -Tasks execute through the same runtime thread/turn pipeline and include: -- linked `thread_id` / `turn_id` -- runtime event count -- timeline + tool summaries + artifact references -- subordinate checklist state from `checklist_*` / legacy `todo_*` tools -- structured verification gates from `task_gate_run` / completed `task_shell_wait` -- PR attempt metadata and patch artifacts -- guarded GitHub write events +## Compatibility tests -Durable tasks are the model-visible work object. Checklist/todo state is progress -inside the active task/thread, not a parallel task system. +Contract snapshots live in `crates/protocol/tests/`. Run: -Task-aware model-visible tools: -- `task_create`, `task_list`, `task_read`, `task_cancel` -- `task_gate_run` -- `task_shell_start`, `task_shell_wait` -- `pr_attempt_record`, `pr_attempt_list`, `pr_attempt_read`, `pr_attempt_preflight` -- `github_issue_context`, `github_pr_context`, `github_comment`, `github_close_issue` +```bash +cargo test -p deepseek-protocol --test parity_protocol --locked +``` -### Automations - -- `GET /v1/automations` -- `POST /v1/automations` -- `GET /v1/automations/{id}` -- `PATCH /v1/automations/{id}` -- `DELETE /v1/automations/{id}` -- `POST /v1/automations/{id}/run` -- `POST /v1/automations/{id}/pause` -- `POST /v1/automations/{id}/resume` -- `GET /v1/automations/{id}/runs?limit=20` - -RRULE support is intentionally constrained to: -- hourly: `FREQ=HOURLY;INTERVAL=[;BYDAY=MO,TU,...]` -- weekly: `FREQ=WEEKLY;BYDAY=...;BYHOUR=<0-23>;BYMINUTE=<0-59>` - -Automations are persisted under `~/.deepseek/automations` (override with `DEEPSEEK_AUTOMATIONS_DIR`). -Each run is executed as a normal background task and links to task/thread/turn ids. - -The same automation manager is exposed to the model through `automation_*` -tools. Create/update/delete/run operations require approval; `automation_run` -and scheduled runs enqueue ordinary durable tasks rather than using a separate -execution path. - -## Persistence - -Runtime store (default under task data root): -- `runtime/threads/*.json` -- `runtime/turns/*.json` -- `runtime/items/*.json` -- `runtime/events/{thread_id}.jsonl` -- `runtime/state.json` (monotonic sequence) - -Task store: -- default `~/.deepseek/tasks` (override with `DEEPSEEK_TASKS_DIR`) - -Both runtime and task state are restart-aware. -Queued or in-progress runtime turns reload as `interrupted`; task execution performs its own recovery on top of the same persisted thread/turn store. +This validates that the app-server's event schema hasn't drifted from the +documented contract. CI runs this on every push to `main` and on release tags.