Implement chat and markdown views, enhance status bar, and add workflow panel

- Added `chat.rs` for rendering chat messages with timestamps and roles.
- Introduced `markdown.rs` for rendering markdown content with styling.
- Created `status.rs` to display the application status bar with session and message counts.
- Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs.
- Established a `theme.rs` for centralized color management across the UI.
- Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
asepharyana
2026-07-11 13:16:10 +07:00
parent 7cb4ae6708
commit cc03bd79b6
131 changed files with 10994 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateDiff {
changes: Vec<Change>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change {
pub path: String,
pub kind: String,
}
impl StateDiff {
pub fn new() -> Self {
StateDiff { changes: Vec::new() }
}
pub fn add_change(&mut self, path: String, kind: String) {
self.changes.push(Change { path, kind });
}
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
pub fn clear(&mut self) {
self.changes.clear();
}
}
pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec<Change> {
if before == after {
return Vec::new();
}
vec![Change {
path: ".".to_string(),
kind: "modified".to_string(),
}]
}
View File
+195
View File
@@ -0,0 +1,195 @@
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::types::Overlay;
#[derive(Clone)]
pub struct DirCache {
entries: Arc<RwLock<Vec<PathBuf>>>,
}
impl DirCache {
pub fn new() -> Self {
DirCache {
entries: Arc::new(RwLock::new(Vec::new())),
}
}
pub async fn set(&self, paths: Vec<PathBuf>) {
let mut w = self.entries.write().await;
*w = paths;
}
pub async fn get(&self) -> Vec<PathBuf> {
let r = self.entries.read().await;
r.clone()
}
}
#[derive(Debug, Clone)]
pub struct ScrollState {
pub offset: usize,
pub max_visible: usize,
}
impl ScrollState {
pub fn new() -> Self {
ScrollState {
offset: 0,
max_visible: 30,
}
}
pub fn scroll_up(&mut self) {
if self.offset > 0 {
self.offset -= 1;
}
}
pub fn scroll_down(&mut self, total: usize) {
let max_offset = total.saturating_sub(self.max_visible);
if self.offset < max_offset {
self.offset += 1;
}
}
pub fn scroll_to_bottom(&mut self, total: usize) {
self.offset = total.saturating_sub(self.max_visible);
}
pub fn set_max_visible(&mut self, max: usize) {
self.max_visible = max;
}
}
#[derive(Debug, Clone)]
pub struct InputState {
pub buffer: String,
pub cursor: usize,
pub history: Vec<String>,
pub history_idx: Option<usize>,
}
impl InputState {
pub fn new() -> Self {
InputState {
buffer: String::new(),
cursor: 0,
history: Vec::new(),
history_idx: None,
}
}
pub fn char_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
}
}
pub fn char_right(&mut self) {
if self.cursor < self.buffer.len() {
self.cursor += 1;
}
}
pub fn insert(&mut self, c: char) {
self.buffer.insert(self.cursor, c);
self.cursor += 1;
}
pub fn delete_left(&mut self) {
if self.cursor > 0 {
self.cursor -= 1;
self.buffer.remove(self.cursor);
}
}
pub fn delete_right(&mut self) {
if self.cursor < self.buffer.len() {
self.buffer.remove(self.cursor);
}
}
pub fn submit(&mut self) -> String {
let result = self.buffer.clone();
if !result.is_empty() {
self.history.push(result.clone());
self.history_idx = None;
}
self.buffer.clear();
self.cursor = 0;
result
}
pub fn history_up(&mut self) {
if self.history.is_empty() {
return;
}
let idx = match self.history_idx {
Some(i) if i > 0 => i - 1,
None => self.history.len() - 1,
Some(_) => return,
};
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
pub fn history_down(&mut self) {
match self.history_idx {
Some(i) if i < self.history.len() - 1 => {
let idx = i + 1;
self.history_idx = Some(idx);
self.buffer = self.history[idx].clone();
self.cursor = self.buffer.len();
}
Some(_) => {
self.history_idx = None;
self.buffer.clear();
self.cursor = 0;
}
None => {}
}
}
pub fn clear(&mut self) {
self.buffer.clear();
self.cursor = 0;
self.history_idx = None;
}
}
#[derive(Debug, Clone)]
pub struct MiscState {
pub overlay: Overlay,
pub toasts: Vec<super::types::Toast>,
pub dirty: bool,
pub yolo_armed: bool,
pub security_armed: bool,
pub security_acknowledged: bool,
pub esc_press_count: u32,
}
impl MiscState {
pub fn new() -> Self {
MiscState {
overlay: Overlay::None,
toasts: Vec::new(),
dirty: true,
yolo_armed: false,
security_armed: false,
security_acknowledged: false,
esc_press_count: 0,
}
}
pub fn push_toast(&mut self, toast: super::types::Toast) {
self.toasts.push(toast);
}
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<super::types::Toast> {
let expired: Vec<_> = self.toasts.iter().filter(|t| t.expired(now_ms)).cloned().collect();
self.toasts.retain(|t| !t.expired(now_ms));
expired
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod input;
pub mod misc;
pub mod rest;
pub mod runtime;
pub mod scroll;
pub mod diff;
pub mod snapshot;
pub mod types;
+125
View File
@@ -0,0 +1,125 @@
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use super::misc::{DirCache, InputState, MiscState, ScrollState};
use super::runtime::SessionRuntime;
use super::types::{AgentMode, Origin, Toast, TranscriptCache};
use crate::model::editlog::EditLog;
use crate::model::settings::Settings;
#[derive(Debug, Clone, PartialEq)]
pub struct ChatMessageDisplay {
pub role: crate::dto::chat::message::Role,
pub content: String,
pub timestamp: i64,
}
impl ChatMessageDisplay {
pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self {
ChatMessageDisplay {
role,
content,
timestamp: chrono::Utc::now().timestamp_millis(),
}
}
}
#[derive(Clone)]
pub struct CronJob {
pub id: String,
pub description: String,
pub cron_expr: String,
pub active: bool,
}
#[derive(Clone)]
pub struct AppStateRest {
pub mode: AgentMode,
pub settings: Settings,
pub workspace_roots: Vec<PathBuf>,
pub session_dir: PathBuf,
pub memory_dir: PathBuf,
pub download_dir: PathBuf,
pub worktrees_dir: PathBuf,
pub current_dir: PathBuf,
pub dir_cache: Arc<RwLock<DirCache>>,
pub edit_log: EditLog,
pub session_runtime: Option<SessionRuntime>,
pub sessions: Vec<crate::model::session::Session>,
pub crons: Vec<CronJob>,
pub transcript_cache: TranscriptCache,
pub scroll: ScrollState,
pub input: InputState,
pub misc: MiscState,
pub pending_api_response: Arc<Mutex<Option<String>>>,
pub dirty: bool,
pub quit: bool,
}
impl AppStateRest {
pub fn new(workspace_roots: Vec<PathBuf>, session_dir: PathBuf, memory_dir: PathBuf) -> Self {
let settings = Settings::load();
let download_dir = memory_dir.parent().unwrap_or(&memory_dir).join("downloads");
let worktrees_dir = memory_dir.parent().unwrap_or(&memory_dir).join("worktrees");
let dir_cache = DirCache::new();
AppStateRest {
mode: AgentMode::Normal,
settings,
workspace_roots,
session_dir: session_dir.clone(),
memory_dir,
download_dir,
worktrees_dir,
current_dir: std::env::current_dir().unwrap_or_default(),
pending_api_response: Arc::new(Mutex::new(None)),
dir_cache: Arc::new(RwLock::new(dir_cache)),
edit_log: EditLog::new(&session_dir),
session_runtime: Some(SessionRuntime::new(session_dir.clone())),
sessions: Vec::new(),
crons: Vec::new(),
transcript_cache: TranscriptCache::new(200),
scroll: ScrollState::new(),
input: InputState::new(),
misc: MiscState::new(),
dirty: true,
quit: false,
}
}
pub fn mode(&self) -> AgentMode {
self.mode
}
pub fn set_mode(&mut self, mode: AgentMode) {
self.mode = mode;
self.dirty = true;
}
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
self.transcript_cache.messages.push(msg);
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
self.transcript_cache.messages.remove(0);
}
self.transcript_cache.dirty = true;
self.dirty = true;
}
pub fn push_toast(&mut self, toast: Toast) {
self.misc.push_toast(toast);
self.dirty = true;
}
pub fn tool_ctx(&self) -> crate::tool::ToolCtx {
crate::tool::ToolCtx {
workspaces: self.workspace_roots.clone(),
session_dir: self.session_dir.clone(),
memory_dir: self.memory_dir.clone(),
download_dir: self.download_dir.clone(),
worktrees_dir: self.worktrees_dir.clone(),
dir_cache: self.dir_cache.clone(),
internet_mode: self.settings.internet_mode.clone(),
origin: Origin::Main,
}
}
}
+105
View File
@@ -0,0 +1,105 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct UsageStats {
pub tokens_in: u64,
pub tokens_out: u64,
pub api_calls: u64,
pub review_tokens: u64,
pub total_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionRuntime {
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
pub tool_call_results: Vec<ToolCallResult>,
pub pending_tool_queue: Vec<PendingTool>,
pub bash_jobs: Vec<BashJobRef>,
pub subagent_queue: usize,
pub edit_count: u32,
pub consecutive_empty_reviews: u32,
pub session_start: i64,
pub lesson_count: u32,
pub lessons_user: u32,
pub lessons_feedback: u32,
pub lessons_project: u32,
pub lessons_reference: u32,
pub lessons_active: u32,
pub lessons_stale: u32,
pub lessons_contradicted: u32,
pub lessons_human: u32,
pub lessons_verified: u32,
pub lessons_unverified: u32,
pub review_count: u32,
pub session_dir: PathBuf,
pub usage: UsageStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult {
pub tool_call_id: String,
pub tool_name: String,
pub output: String,
pub is_error: bool,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingTool {
pub tool_name: String,
pub args: serde_json::Value,
pub execution_model: crate::app::state::types::ExecutionModel,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BashJobRef {
pub id: String,
pub command: String,
pub started_at: i64,
pub running: bool,
}
impl SessionRuntime {
pub fn new(session_dir: PathBuf) -> Self {
SessionRuntime {
messages: Vec::new(),
tool_call_results: Vec::new(),
pending_tool_queue: Vec::new(),
bash_jobs: Vec::new(),
subagent_queue: 0,
edit_count: 0,
consecutive_empty_reviews: 0,
session_start: chrono::Utc::now().timestamp_millis(),
lesson_count: 0,
lessons_user: 0,
lessons_feedback: 0,
lessons_project: 0,
lessons_reference: 0,
lessons_active: 0,
lessons_stale: 0,
lessons_contradicted: 0,
lessons_human: 0,
lessons_verified: 0,
lessons_unverified: 0,
review_count: 0,
session_dir,
usage: UsageStats::default(),
}
}
pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) {
self.messages.push(msg);
}
pub fn record_api_call(&mut self, tokens_in: u64, tokens_out: u64, duration_ms: u64) {
self.usage.tokens_in += tokens_in;
self.usage.tokens_out += tokens_out;
self.usage.api_calls += 1;
self.usage.total_ms += duration_ms;
}
pub fn record_review_tokens(&mut self, tokens: u64) {
self.usage.review_tokens += tokens;
}
}
View File
+22
View File
@@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateSnapshot {
pub snapshot: serde_json::Value,
}
impl StateSnapshot {
pub fn new() -> Self {
StateSnapshot {
snapshot: serde_json::json!({}),
}
}
}
pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result<Vec<u8>> {
Ok(serde_json::to_vec(snapshot)?)
}
pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result<StateSnapshot> {
Ok(serde_json::from_slice(data)?)
}
+141
View File
@@ -0,0 +1,141 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AgentMode {
Auto,
Normal,
Plan,
Yolo,
}
impl AgentMode {
pub fn auto_approve(&self) -> bool {
matches!(self, AgentMode::Auto | AgentMode::Yolo)
}
pub fn name(&self) -> &'static str {
match self {
AgentMode::Auto => "Auto",
AgentMode::Normal => "Normal",
AgentMode::Plan => "Plan",
AgentMode::Yolo => "Yolo",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PanelKind {
Chat,
Agents,
Bash,
Workflow,
Help,
SessionHub,
}
impl PanelKind {
pub fn name(&self) -> &'static str {
match self {
PanelKind::Chat => "Chat",
PanelKind::Agents => "Agents",
PanelKind::Bash => "Bash",
PanelKind::Workflow => "Workflow",
PanelKind::Help => "Help",
PanelKind::SessionHub => "Sessions",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToastKind {
Info,
Success,
Warning,
Error,
Lesson,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Toast {
pub kind: ToastKind,
pub message: String,
pub created_at: i64,
pub lifetime_ms: u64,
}
impl Toast {
pub fn new(kind: ToastKind, message: String) -> Self {
Toast {
kind,
message,
created_at: chrono::Utc::now().timestamp_millis(),
lifetime_ms: 5000,
}
}
pub fn expired(&self, now_ms: i64) -> bool {
now_ms - self.created_at > self.lifetime_ms as i64
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Overlay {
None,
Help,
Settings,
Agents,
Bash,
QuitConfirm,
SessionHub,
Workflow,
Onboard,
OnboardProvider,
Picker,
KeyInput,
Editor,
Effort,
Mcp,
Security,
Todo,
Rewind,
Learning,
Usage,
Loading,
}
impl Overlay {
pub fn is_active(self) -> bool {
!matches!(self, Overlay::None)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TranscriptCache {
pub messages: Vec<super::rest::ChatMessageDisplay>,
pub max_lines: usize,
pub dirty: bool,
}
impl TranscriptCache {
pub fn new(max_lines: usize) -> Self {
TranscriptCache {
messages: Vec::new(),
max_lines,
dirty: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExecutionModel {
Inline,
Deferred,
AsyncTokio,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub enum Origin {
Main,
SubAgent,
Reviewer,
}