codewhale-tui 0.9.2

Terminal UI for open-source and open-weight coding models
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! Per-workspace git context shown in the composer header.
//!
//! The TUI shows a "branch | clean/N modified/…" badge sourced from
//! `git status` and `git rev-parse`. To avoid spawning git on every
//! render, the result is cached and only refreshed every
//! `REFRESH_SECS` seconds. The refresh prefers spawn-blocking on the
//! current Tokio runtime; tests and non-async callers fall through to
//! a synchronous call.

use crate::dependencies::{ExternalTool, Git};
use std::path::Path;
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;

use crate::tui::app::App;

/// How often (seconds) the workspace context badge is allowed to
/// re-query git. Exposed for tests that exercise the TTL.
pub(crate) const REFRESH_SECS: u64 = 15;

/// Pull a fresh workspace context from disk if the cached value is
/// older than [`REFRESH_SECS`] and `allow_refresh` is true. Always
/// drains any pending async result into `app.workspace_context` first
/// so the render pass sees the latest value (#399 S1).
pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
    // Drain the async cell result into the live field first, so the render
    // path always reads the latest value (#399 S1).
    if let Ok(mut cell) = app.workspace_context_cell.lock()
        && let Some(ctx) = cell.take()
    {
        if app.workspace_context.as_deref() != Some(ctx.as_str()) {
            app.needs_redraw = true;
        }
        app.workspace_context = Some(ctx);
    }

    if app
        .workspace_context_refreshed_at
        .is_some_and(|refreshed_at| {
            now.duration_since(refreshed_at) < Duration::from_secs(REFRESH_SECS)
        })
    {
        return;
    }

    if !allow_refresh {
        return;
    }

    // The Session sidebar shows the memory file's size every frame it is
    // visible. Stat it here, on the same TTL as the git context, so the draw
    // closure reads a cached string instead of issuing a syscall per frame
    // (#3908). Cheap on a local disk; tens of ms on NFS/SSHFS/cloud-synced
    // home directories, which is exactly where the stutter was reported.
    refresh_memory_size_hint(app);

    // Offload git query to a background thread when a Tokio runtime is
    // available. Fall back to synchronous execution for tests and other
    // non-async contexts (#399 S1).
    if let Ok(handle) = tokio::runtime::Handle::try_current() {
        let ctx = app.workspace_context_cell.clone();
        let workspace = app.workspace.clone();
        handle.spawn_blocking(move || {
            let result = collect(&workspace);
            if let Ok(mut guard) = ctx.lock() {
                *guard = result;
            }
        });
    } else {
        // No runtime — run synchronously so tests and one-shot callers
        // still get a result immediately.
        app.workspace_context = collect(&app.workspace);
    }
    app.workspace_context_refreshed_at = Some(now);
}

/// Re-read the memory file's size into [`App::memory_size_hint`].
///
/// A missing or unreadable file renders as an em dash, matching what the
/// sidebar showed when it stat-ed inline.
fn refresh_memory_size_hint(app: &mut App) {
    let hint = if app.use_memory {
        Some(
            std::fs::metadata(&app.memory_path)
                .map(|meta| format_size(meta.len()))
                .unwrap_or_else(|_| "\u{2014}".to_string()),
        )
    } else {
        None
    };
    if app.memory_size_hint != hint {
        app.needs_redraw = true;
        app.memory_size_hint = hint;
    }
}

/// Human-readable byte size, in the exact shape the sidebar rendered inline.
fn format_size(bytes: u64) -> String {
    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!("{bytes} B")
    }
}

/// Force a workspace-context re-query on the next render tick, bypassing the
/// normal TTL. Keeps the current value visible while the background git query
/// is running.
pub(super) fn refresh_now(app: &mut App, now: Instant) {
    if let Ok(mut cell) = app.workspace_context_cell.lock() {
        *cell = None;
    }
    app.workspace_context_refreshed_at = None;
    refresh_if_needed(app, now, true);
}

#[derive(Debug, Default, Clone, Copy)]
struct ChangeSummary {
    staged: usize,
    modified: usize,
    untracked: usize,
    conflicts: usize,
}

impl ChangeSummary {
    fn is_clean(&self) -> bool {
        self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
    }
}

/// Build the human-readable workspace context string ("branch | status")
/// from `git rev-parse` + `git status`. Returns `None` if the workspace
/// is not a git repository or git itself is unavailable.
pub(crate) fn collect(workspace: &Path) -> Option<String> {
    let branch = branch(workspace)?;
    let summary = change_summary(workspace)?;

    let mut parts = Vec::new();
    if summary.staged > 0 {
        parts.push(format!("{} staged", summary.staged));
    }
    if summary.modified > 0 {
        parts.push(format!("{} modified", summary.modified));
    }
    if summary.untracked > 0 {
        parts.push(format!("{} untracked", summary.untracked));
    }
    if summary.conflicts > 0 {
        parts.push(format!("{} conflicts", summary.conflicts));
    }

    let status = if summary.is_clean() {
        "clean".to_string()
    } else {
        parts.join(", ")
    };

    Some(format!("{branch} | {status}"))
}

pub(crate) fn branch_from_context(context: &str) -> Option<&str> {
    let (branch, _) = context.rsplit_once(" | ")?;
    (!branch.is_empty()).then_some(branch)
}

/// Concise, factual workspace identity for the footer status chip (#3188).
///
/// The identity is sourced from workspace/git detection only — never from
/// model narration or config text. `name` is the workspace basename, `branch`
/// is `Some` only when the workspace is a git repository (carrying the cached
/// `"detached:<hash>"` form for detached HEAD), and `is_git` distinguishes a
/// real repo from a plain directory so the footer can show an explicit
/// non-repo state instead of an empty `Repo:` label.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspaceIdentity {
    pub name: String,
    pub branch: Option<String>,
    pub is_git: bool,
}

/// Basename used as the workspace identity. Falls back to a stable sentinel
/// when the path has no final component (filesystem root). Derived purely
/// from the workspace path, so it never spawns git on the render path.
pub(crate) fn workspace_basename(workspace: &Path) -> String {
    workspace
        .file_name()
        .and_then(|s| s.to_str())
        .filter(|s| !s.is_empty())
        .unwrap_or("(root)")
        .to_string()
}

/// Resolve the footer identity from the workspace path plus the cached
/// "branch | status" context string. `context` is `None` when the workspace
/// is not a git repository (or git is unavailable), which we surface as an
/// explicit non-repo state rather than hiding the chip.
pub(crate) fn identity_from_context(workspace: &Path, context: Option<&str>) -> WorkspaceIdentity {
    let branch = context.and_then(branch_from_context).map(str::to_string);
    WorkspaceIdentity {
        name: workspace_basename(workspace),
        is_git: branch.is_some(),
        branch,
    }
}

/// Render the footer repo label, keeping the most useful identity when width
/// is constrained (#3188 acceptance criteria). Layout priority, widest first:
///
/// 1. `Repo: <name> @ <branch>` (git repo, room for both)
/// 2. `Repo: <name>` (drop the branch before truncating the name)
/// 3. `Repo: <truncated name…>` then the bare label when truly tiny
///
/// Non-git workspaces render `Repo: <name> (no git)`, degrading to
/// `Repo: <name>` and then truncation under width pressure. Returns an empty
/// string only when `max_width` cannot fit even the `Repo:` prefix.
pub(crate) fn format_repo_identity(identity: &WorkspaceIdentity, max_width: usize) -> String {
    use crate::localization::truncate_to_width;

    const PREFIX: &str = "Repo: ";
    let prefix_width = PREFIX.width();
    if max_width < prefix_width {
        return String::new();
    }

    // Candidates from richest to leanest; the first that fits wins.
    let mut candidates: Vec<String> = Vec::new();
    match (&identity.branch, identity.is_git) {
        (Some(branch), _) => {
            candidates.push(format!("{PREFIX}{} @ {branch}", identity.name));
            candidates.push(format!("{PREFIX}{}", identity.name));
        }
        (None, _) => {
            candidates.push(format!("{PREFIX}{} (no git)", identity.name));
            candidates.push(format!("{PREFIX}{}", identity.name));
        }
    }

    for candidate in &candidates {
        if candidate.width() <= max_width {
            return candidate.clone();
        }
    }

    // Even the lean form overflows: keep the prefix + a truncated name so the
    // identity never collapses into a bare, useless `Repo:` label.
    truncate_to_width(&format!("{PREFIX}{}", identity.name), max_width)
}

pub(super) fn branch(workspace: &Path) -> Option<String> {
    let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
    let branch = branch.trim().to_string();
    if branch == "HEAD" || branch.is_empty() {
        let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
        let short_hash = short_hash.trim();
        if short_hash.is_empty() {
            return None;
        }
        return Some(format!("detached:{short_hash}"));
    }
    Some(branch)
}

fn change_summary(workspace: &Path) -> Option<ChangeSummary> {
    let status = run_git(
        workspace,
        &["status", "--short", "--untracked-files=normal"],
    )
    .ok()?;

    if status.trim().is_empty() {
        return Some(ChangeSummary::default());
    }

    let mut summary = ChangeSummary::default();
    for line in status.lines() {
        if line.trim().is_empty() {
            continue;
        }

        let mut chars = line.chars();
        let staged = chars.next()?;
        let modified = chars.next().unwrap_or(' ');

        if staged == ' ' && modified == ' ' {
            continue;
        }
        if staged == '?' && modified == '?' {
            summary.untracked = summary.untracked.saturating_add(1);
            continue;
        }

        if staged == 'U' || modified == 'U' {
            summary.conflicts = summary.conflicts.saturating_add(1);
        }
        if staged != ' ' && staged != '?' {
            summary.staged = summary.staged.saturating_add(1);
        }
        if modified != ' ' && modified != '?' {
            summary.modified = summary.modified.saturating_add(1);
        }
    }

    Some(summary)
}

fn run_git(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
    let output = Git::output(args, workspace)?;
    if !output.status.success() {
        return Err(std::io::Error::other("git command failed"));
    }
    Ok(String::from_utf8_lossy(&output.stdout).to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    #[test]
    fn memory_size_hint_is_cached_off_the_render_path() {
        // #3908: the Session sidebar rendered this by stat-ing the memory file
        // inside the draw closure, once per frame. The stat now happens here,
        // on the workspace-context TTL, so the sidebar reads a plain String.
        let dir = tempfile::tempdir().expect("temp dir");
        let memory = dir.path().join("MEMORY.md");
        std::fs::write(&memory, vec![b'x'; 2048]).unwrap();

        let mut app = crate::tui::app::App::new(
            crate::test_support::test_tui_options(dir.path()),
            &crate::config::Config::default(),
        );
        app.use_memory = true;
        app.memory_path = memory.clone();

        refresh_memory_size_hint(&mut app);
        assert_eq!(app.memory_size_hint.as_deref(), Some("2.0 KB"));

        // A file that is not there reads the same as one we cannot stat: the
        // sidebar's original em dash, not a crash or a stale number.
        std::fs::remove_file(&memory).unwrap();
        refresh_memory_size_hint(&mut app);
        assert_eq!(app.memory_size_hint.as_deref(), Some("\u{2014}"));

        // Memory off means nothing to show at all.
        app.use_memory = false;
        refresh_memory_size_hint(&mut app);
        assert_eq!(app.memory_size_hint, None);
    }

    #[test]
    fn memory_size_formats_match_the_sidebar_original() {
        assert_eq!(format_size(512), "512 B");
        assert_eq!(format_size(1024), "1.0 KB");
        assert_eq!(format_size(1024 * 1024), "1.0 MB");
    }

    #[test]
    fn identity_in_git_repo_carries_name_and_branch() {
        let id = identity_from_context(
            &PathBuf::from("/work/CodeWhale"),
            Some("codex/v0.8.61 | 3 modified"),
        );
        assert_eq!(id.name, "CodeWhale");
        assert_eq!(id.branch.as_deref(), Some("codex/v0.8.61"));
        assert!(id.is_git);
        // Full-width render keeps both the repo identity and the branch.
        assert_eq!(
            format_repo_identity(&id, 80),
            "Repo: CodeWhale @ codex/v0.8.61"
        );
    }

    #[test]
    fn identity_outside_git_uses_cwd_basename_with_explicit_state() {
        // `None` context == not a git repo / git unavailable. We must not show
        // a stale repo, but we also must not collapse to an empty `Repo:`.
        let id = identity_from_context(&PathBuf::from("/tmp/scratch-dir"), None);
        assert_eq!(id.name, "scratch-dir");
        assert_eq!(id.branch, None);
        assert!(!id.is_git);
        assert_eq!(format_repo_identity(&id, 80), "Repo: scratch-dir (no git)");
    }

    #[test]
    fn detached_head_branch_passes_through_to_label() {
        // `branch()` encodes detached HEAD as "detached:<hash>"; the footer
        // must surface that verbatim rather than dropping the identity.
        let id = identity_from_context(
            &PathBuf::from("/work/CodeWhale"),
            Some("detached:ae101a1 | clean"),
        );
        assert_eq!(id.branch.as_deref(), Some("detached:ae101a1"));
        assert_eq!(
            format_repo_identity(&id, 80),
            "Repo: CodeWhale @ detached:ae101a1"
        );
    }

    #[test]
    fn narrow_width_keeps_identity_over_branch_then_truncates() {
        let id = identity_from_context(
            &PathBuf::from("/work/CodeWhale"),
            Some("codex/v0.8.61 | clean"),
        );

        // Too narrow for "name @ branch" -> drop the branch, keep the name.
        let dropped = format_repo_identity(&id, 20);
        assert_eq!(dropped, "Repo: CodeWhale");
        assert!(dropped.width() <= 20);

        // Too narrow even for the name -> truncate but keep the prefix so the
        // chip never becomes a bare, useless "Repo:" label.
        let truncated = format_repo_identity(&id, 11);
        assert!(truncated.width() <= 11, "{truncated:?} must fit width 11");
        assert!(truncated.starts_with("Repo: "), "{truncated:?}");
        assert!(truncated.ends_with(''), "{truncated:?}");

        // Below the bare "Repo:" prefix -> render nothing so the footer hides
        // the chip cleanly instead of printing garbage.
        assert_eq!(format_repo_identity(&id, 3), "");
    }

    #[test]
    fn non_git_identity_degrades_before_truncating() {
        let id = identity_from_context(&PathBuf::from("/tmp/scratch-dir"), None);
        // No room for the "(no git)" suffix -> fall back to just the name.
        assert_eq!(format_repo_identity(&id, 18), "Repo: scratch-dir");
    }

    #[test]
    fn workspace_basename_handles_root_path() {
        assert_eq!(workspace_basename(Path::new("/")), "(root)");
        assert_eq!(workspace_basename(Path::new("/a/b/project")), "project");
    }

    #[test]
    fn collect_and_identity_agree_on_a_real_repo() {
        // Real-git integration: in an actual worktree, `collect()` yields a
        // "branch | status" string and `identity_from_context` must read a
        // git identity back out of it. Skipped when git is unavailable
        // (mirrors dependencies::external_tool_output_respects_cwd).
        if !Git::available() {
            return;
        }
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path();
        // `git init` so the directory is a real repo with a HEAD.
        let init = Git::output(&["init", "-q"], root);
        if init.is_err() || !init.unwrap().status.success() {
            return; // hermetic CI without writable git config: skip.
        }
        let _ = Git::output(&["config", "user.email", "t@example.com"], root);
        let _ = Git::output(&["config", "user.name", "Test"], root);

        match collect(root) {
            Some(ctx) => {
                let id = identity_from_context(root, Some(ctx.as_str()));
                assert!(id.is_git, "fresh repo should detect a git identity");
                assert!(id.branch.is_some(), "repo must report a branch/HEAD");
                let label = format_repo_identity(&id, 80);
                assert!(label.starts_with("Repo: "), "{label:?}");
            }
            None => {
                // Some sandboxes report no branch on an empty repo; the
                // non-git fallback must still produce a usable label.
                let id = identity_from_context(root, None);
                assert!(!id.is_git);
                assert!(format_repo_identity(&id, 80).starts_with("Repo: "));
            }
        }
    }
}