codewhale-tui 0.9.8

Terminal UI for open-source and open-weight coding models
Documentation
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
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
//! Opening, closing, and refreshing the overlay surfaces (pagers, inspectors,
//! backtrack, hotbar) layered over the transcript.
//!
//! Moved verbatim out of `ui.rs`.

use super::*;

pub(crate) fn open_setup_checkpoint_if_due(
    app: &mut App,
    config: &Config,
    skip_onboarding: bool,
) -> bool {
    if skip_onboarding {
        if crate::tui::setup::should_open_update_checkpoint(app, config)
            && let Err(err) = crate::tui::setup::defer_update_checkpoint_for_app(app, config)
        {
            tracing::warn!(
                target: "tui::setup",
                "failed to record deferred setup checkpoint: {err}"
            );
        }
        return false;
    }
    if app.onboarding != crate::tui::app::OnboardingState::None
        || app.view_stack.top_kind() == Some(ModalKind::SetupWizard)
        || !crate::tui::setup::should_open_update_checkpoint(app, config)
    {
        return false;
    }

    // A fresh wizard invalidates any in-flight model draft from a prior one.
    let _ = app.next_draft_gen();
    app.view_stack
        .push(crate::tui::setup::SetupWizardView::new_for_app(app, config));
    true
}

/// Ctrl+O — the one gesture that selects "explore offline".
///
/// A plain letter cannot be used: the provider picker key-entry stage is a
/// text field and would swallow it into the draft secret.
pub(crate) fn is_explore_offline_shortcut(key: &KeyEvent) -> bool {
    matches!(key.code, KeyCode::Char('o') | KeyCode::Char('O'))
        && key.modifiers.contains(KeyModifiers::CONTROL)
}

/// Open the one canonical theme surface for the appearance step (#3937).
///
/// This is the same `ThemePickerView` `/theme` uses, so onboarding inherits its
/// transactional contract wholesale: navigating previews live through a
/// non-persisting `ConfigUpdated`, Enter persists, and Escape reverts to the
/// theme captured here. There is no second theme list and no second registry.
pub(crate) fn open_onboarding_theme_picker(app: &mut App) {
    if app.onboarding != OnboardingState::Appearance
        || app.view_stack.top_kind() == Some(ModalKind::ThemePicker)
    {
        return;
    }
    let original = app.theme_id.name().to_string();
    app.view_stack.push_boxed(
        crate::tui::theme_picker::ThemePickerView::boxed_with_treatment(
            original,
            app.ocean_treatment,
            app.ui_locale,
            app.background_color_override,
        ),
    );
    app.needs_redraw = true;
}

/// Choose which durable-task summaries should appear in the Work
/// sidebar's Tasks panel.
///
/// Tasks stamped with the current session owner stay visible on that session's
/// live surface. Tasks owned by a different session stay in explicit history
/// (`/tasks`) instead of appearing as live workspace work. Legacy unowned
/// records fall back to the v0.9.1 timestamp gate: active tasks remain visible,
/// while terminal receipts must have both creation and completion times inside
/// this TUI session. Durable tasks are stored per user rather than per TUI
/// process, and startup recovery can stamp an old running record with a fresh
/// `ended_at`. Treating that as a current receipt makes a new same-workspace
/// instance look failed (#4416).
///
/// A terminal task missing `ended_at` is treated as not current and
/// dropped: durable tasks always stamp `ended_at` when they reach a
/// terminal state, so absence of it indicates a record from a much
/// older schema and isn't worth surfacing.
pub(crate) fn select_work_sidebar_tasks(
    tasks: Vec<TaskSummary>,
    session_started_at: chrono::DateTime<chrono::Utc>,
    current_session_id: Option<&str>,
) -> Vec<TaskSummary> {
    tasks
        .into_iter()
        .filter(|task| {
            let owner_matches_current = current_session_id
                .zip(task.owner_session_id.as_deref())
                .is_some_and(|(current, owner)| current == owner);
            let owned_by_other_session = current_session_id.is_some()
                && task
                    .owner_session_id
                    .as_deref()
                    .is_some_and(|owner| Some(owner) != current_session_id);
            if owned_by_other_session {
                return false;
            }
            match task.status {
                TaskStatus::Queued | TaskStatus::Running => {
                    owner_matches_current || task.owner_session_id.is_none()
                }
                TaskStatus::Completed | TaskStatus::Failed | TaskStatus::Canceled => {
                    // A terminal task missing `ended_at` predates the schema
                    // that always stamps it; never surface it as a live
                    // receipt, even when it names this session as owner.
                    if task.ended_at.is_none() {
                        return false;
                    }
                    owner_matches_current
                        || (task.owner_session_id.is_none()
                            && task.created_at >= session_started_at
                            && task
                                .ended_at
                                .is_some_and(|ended_at| ended_at >= session_started_at))
                }
            }
        })
        .collect()
}

pub(crate) fn toggle_settings_view(app: &mut App) {
    if app.view_stack.contains_kind(ModalKind::Config) {
        app.view_stack.pop_through_kind(ModalKind::Config);
    } else {
        app.view_stack.push(ConfigView::new_for_app(app));
    }
    app.needs_redraw = true;
}

pub(crate) fn clear_work_inspector_after_pager_close(app: &mut App, was_work_inspector: bool) {
    if was_work_inspector && app.view_stack.top_kind() != Some(ModalKind::Pager) {
        app.work_surface.opened = None;
    }
}

pub(crate) fn hotbar_slot_from_key(app: &App, key: &event::KeyEvent) -> Option<u8> {
    let KeyCode::Char(c) = key.code else {
        return None;
    };
    if !('1'..='8').contains(&c) {
        return None;
    }
    let slot = c.to_digit(10).and_then(|digit| u8::try_from(digit).ok())?;

    if key.modifiers.contains(KeyModifiers::ALT)
        && !key.modifiers.contains(KeyModifiers::CONTROL)
        && !key.modifiers.contains(KeyModifiers::SUPER)
    {
        if app.onboarding != OnboardingState::None
            || !app.view_stack.is_empty()
            || app.is_history_search_active()
            || !visible_slash_menu_entries(app, SLASH_MENU_LIMIT).is_empty()
        {
            return None;
        }

        return Some(slot);
    }

    None
}

pub(crate) fn is_permission_cycle_shortcut(key: &KeyEvent) -> bool {
    let forbidden = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
    if key.modifiers.intersects(forbidden) {
        return false;
    }
    matches!(key.code, KeyCode::BackTab)
        || (matches!(key.code, KeyCode::Tab) && key.modifiers.contains(KeyModifiers::SHIFT))
}

pub(crate) async fn cycle_permission_posture(
    app: &mut App,
    config: &mut Config,
    engine_handle: &EngineHandle,
) {
    let control = config.approval_policy_control(
        app.config_path.as_deref(),
        app.config_profile.as_deref(),
        &app.workspace,
    );
    let changed = if control == crate::config::ApprovalPolicyControl::RootConfig {
        app.cycle_root_approval_posture()
    } else {
        app.cycle_approval_posture()
    };
    if changed {
        if control == crate::config::ApprovalPolicyControl::RootConfig {
            config.approval_policy = None;
        }
        sync_mode_update(app, engine_handle).await;
        refresh_config_view_if_open(app, "permission_posture");
    }
}

/// Open the one canonical provider setup surface for onboarding.  Fresh
/// onboarding starts at the full catalog; missing-key recovery focuses the
/// current route so an exact Kimi Code K3 configuration can expose its plan
/// route before a secret is entered.  Either way the picker opens on the
/// navigable list (#4763): onboarding never drops a user straight into a
/// key/OAuth prompt for a route they were not shown.
pub(crate) async fn open_onboarding_provider_picker(
    app: &mut App,
    config: &Config,
    engine_handle: &EngineHandle,
    focus_current_route: bool,
) {
    if app.onboarding != OnboardingState::Provider
        || app.view_stack.top_kind() == Some(ModalKind::ProviderPicker)
    {
        return;
    }
    let runtime_status = query_provider_runtime_status(engine_handle).await;
    app.view_stack.push(
        crate::tui::provider_picker::ProviderPickerView::new_for_onboarding(
            app.api_provider,
            focus_current_route.then_some(app.onboarding_provider),
            config,
            runtime_status,
        )
        .with_locale(app.ui_locale)
        .with_provider_health(&app.provider_health),
    );
    app.needs_redraw = true;
}

pub(crate) fn open_text_pager(app: &mut App, title: String, content: String) {
    let width = app
        .viewport
        .last_transcript_area
        .map(|area| area.width)
        .unwrap_or(80);
    app.view_stack.push(PagerView::from_text(
        title,
        &content,
        width.saturating_sub(2),
    ));
}

pub(crate) fn open_context_inspector(app: &mut App) {
    app.view_stack.push(ContextInspectorView::new(app));
}

pub(crate) fn open_external_url(url: &str) -> Result<()> {
    crate::utils::open_url(url)
}

/// Pull the latest snapshot of cells / revisions / render options into the
/// live transcript overlay sitting on top of the view stack. No-op if the
/// top view isn't a `LiveTranscriptOverlay`.
pub(crate) fn refresh_live_transcript_overlay(app: &mut App) {
    // Pop+push lets us hold &mut to the overlay while also borrowing `app`
    // mutably for the snapshot — direct re-borrow through `view_stack`
    // would otherwise alias `app`.
    let Some(mut overlay) = app.view_stack.pop() else {
        return;
    };
    if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
        typed.refresh_from_app(app);
    }
    app.view_stack.push_boxed(overlay);
}

pub(crate) fn refresh_context_inspector_overlay(app: &mut App) {
    let Some(mut overlay) = app.view_stack.pop() else {
        return;
    };
    if let Some(typed) = overlay.as_any_mut().downcast_mut::<ContextInspectorView>() {
        typed.refresh_from_app(app);
    }
    app.view_stack.push_boxed(overlay);
}

/// Open the live transcript overlay in backtrack-preview mode (#133).
/// The overlay starts highlighting the most recent user message
/// (`selected_idx = 0`) and routes Left/Right/Enter/Esc through
/// `ViewEvent::Backtrack*` so the main key dispatcher can advance the
/// `BacktrackState` and apply the rewind on confirm.
pub(crate) fn open_backtrack_overlay(app: &mut App) {
    let mut overlay = LiveTranscriptOverlay::new();
    overlay.refresh_from_app(app);
    overlay.set_backtrack_preview(0);
    app.view_stack.push(overlay);
    app.status_message =
        Some("Backtrack: \u{2190}/\u{2192} step  Enter rewind  Esc cancel".to_string());
    app.needs_redraw = true;
}

/// Open a fresh live transcript overlay in sticky-tail mode.
pub(crate) fn open_live_transcript_overlay(app: &mut App) {
    if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
        return;
    }
    let mut overlay = LiveTranscriptOverlay::new();
    overlay.refresh_from_app(app);
    app.view_stack.push(overlay);
    app.status_message = Some("Live transcript: tailing (Esc to close)".to_string());
    app.needs_redraw = true;
}

/// Toggle the live transcript overlay on `Ctrl+Shift+T`. Closes the overlay if it's
/// already on top; otherwise uses the same open path as `/transcript`.
pub(crate) fn toggle_live_transcript_overlay(app: &mut App) {
    if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
        app.view_stack.pop();
        app.needs_redraw = true;
        return;
    }
    open_live_transcript_overlay(app);
}

/// Open the `/model` picker pre-filtered to `provider` (#3083). The model
/// picker's search already scopes rows by provider display name, so we reuse
/// the standard "open model picker" path and seed its query by replaying the
/// provider's display name as character input through the public view-stack
/// key path — no model-picker internals are touched.
pub(crate) fn open_model_picker_for_provider(
    app: &mut App,
    config: &Config,
    provider: crate::config::ApiProvider,
) {
    if app.view_stack.top_kind() != Some(ModalKind::ModelPicker) {
        app.view_stack
            .push(crate::tui::model_picker::ModelPickerView::new(app, config));
    }
    for ch in provider.display_name().chars() {
        // Char input updates the query and never emits a ViewEvent, so the
        // returned (empty) event list is safe to drop.
        let _ = app.view_stack.handle_key(crossterm::event::KeyEvent::new(
            KeyCode::Char(ch),
            KeyModifiers::NONE,
        ));
    }
    app.needs_redraw = true;
}

/// Hide the Hotbar: persist `hotbar = []` (the canonical "disabled" state) and
/// clear the live in-memory slots so the panel disappears immediately. The
/// explicit empty array — not a missing key — is what disables defaults, so we
/// store `Some(vec![])` rather than `None`.
pub(crate) fn disable_hotbar(app: &mut App, config: &mut Config) {
    match crate::config_persistence::persist_hotbar_bindings(app.config_path.as_deref(), &[]) {
        Ok(path) => {
            config.hotbar = Some(Vec::new());
            app.status_message = Some(format!(
                "Hotbar hidden (hotbar = [] in {}). Bring it back with `/hotbar on`.",
                path.display()
            ));
        }
        Err(err) => {
            app.status_message = Some(format!("Failed to hide Hotbar: {err}"));
            app.add_message(HistoryCell::System {
                content: format!("Failed to hide Hotbar: {err}"),
            });
        }
    }
    app.needs_redraw = true;
}

pub(crate) fn refresh_config_view_if_open(app: &mut App, focus_key: &str) {
    if app.view_stack.top_kind() == Some(ModalKind::Config) {
        let filter = app.view_stack.pop().and_then(|mut view| {
            view.as_any_mut()
                .downcast_mut::<ConfigView>()
                .map(|config_view| config_view.filter_query().to_string())
        });
        let mut config_view = ConfigView::new_for_app(app);
        if let Some(filter) = filter {
            config_view.restore_filter(filter);
        }
        config_view.focus_key(focus_key);
        app.view_stack.push(config_view);
    }
}

pub(crate) fn refresh_skills_manager_if_open(
    app: &mut App,
    status: Option<String>,
    focus: Option<&crate::skills::audit::AuditedSkillId>,
) {
    if app.view_stack.top_kind() != Some(ModalKind::SkillsManager) {
        return;
    }
    let Some(mut boxed) = app.view_stack.pop() else {
        return;
    };
    let rebuilt = if let Some(prev) = boxed
        .as_any_mut()
        .downcast_mut::<crate::tui::views::skills_manager::SkillsManagerView>(
    ) {
        crate::tui::views::skills_manager::SkillsManagerView::rebuild_preserving(
            app, prev, status, focus,
        )
    } else {
        crate::tui::views::skills_manager::SkillsManagerView::new(app)
    };
    app.view_stack.push(rebuilt);
}

#[allow(clippy::too_many_arguments)]
pub(crate) fn push_approval_request_view(
    app: &mut App,
    id: &str,
    tool_name: &str,
    description: &str,
    tool_input: &serde_json::Value,
    approval_key: &str,
    intent_summary: Option<&str>,
    default_selection: crate::config::ApprovalDefaultSelection,
) {
    let request = ApprovalRequest::new_with_intent(
        id,
        tool_name,
        description,
        tool_input,
        approval_key,
        intent_summary,
        &app.workspace,
    );
    app.view_stack
        .push(ApprovalView::new_with_default_selection(
            request,
            app.ui_locale,
            default_selection,
        ));
}

/// Push the new `selected_idx` into the live transcript overlay so the
/// highlight follows the user's Left/Right input. No-op if the overlay is
/// no longer on top (e.g. it was closed underneath us).
pub(crate) fn update_backtrack_overlay_selection(app: &mut App, selected_idx: usize) {
    if app.view_stack.top_kind() != Some(ModalKind::LiveTranscript) {
        return;
    }
    let Some(mut overlay) = app.view_stack.pop() else {
        return;
    };
    if let Some(typed) = overlay.as_any_mut().downcast_mut::<LiveTranscriptOverlay>() {
        typed.set_backtrack_preview(selected_idx);
    }
    app.view_stack.push_boxed(overlay);
    app.needs_redraw = true;
}

/// Apply the user's backtrack selection: trim `app.history` and
/// `app.api_messages` so everything from the chosen user message onward
/// is dropped, populate the composer with the dropped user text, close
/// the overlay, and surface a status hint. The cycle counter is bumped
/// so any persistent indices clear; the engine's in-flight context is
/// re-synced via `Op::SyncSession` so the next turn starts fresh.
/// Index in `api_messages` to truncate to for a backtrack of `depth` visible
/// user prompts from the tail. Counts only messages that yield a
/// `HistoryCell::User` (a real prompt), NOT tool-result messages which are
/// also stored with `role == "user"`. Returns `None` if fewer than `depth`
/// user prompts exist.
pub(crate) fn backtrack_api_cut_index(api_messages: &[Message], depth: usize) -> Option<usize> {
    let mut user_seen = 0usize;
    for (idx, msg) in api_messages.iter().enumerate().rev() {
        let yields_user = history_cells_from_message(msg)
            .iter()
            .any(|cell| matches!(cell, HistoryCell::User { .. }));
        if yields_user {
            if user_seen == depth {
                return Some(idx);
            }
            user_seen += 1;
        }
    }
    None
}

pub(crate) fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool {
    let line_meta = app.viewport.transcript_cache.line_meta();
    if line_meta.is_empty() {
        return false;
    }

    let top = app
        .viewport
        .last_transcript_top
        .min(line_meta.len().saturating_sub(1));
    let current_cell = line_meta
        .get(top)
        .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line)
        .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index));

    let mut scan_indices = Vec::new();
    match direction {
        SearchDirection::Forward => {
            scan_indices.extend((top.saturating_add(1))..line_meta.len());
        }
        SearchDirection::Backward => {
            scan_indices.extend((0..top).rev());
        }
    }

    for idx in scan_indices {
        let Some((cell_index, _)) = line_meta[idx].cell_line() else {
            continue;
        };
        let cell_index = app.original_cell_index_for_rendered(cell_index);
        if current_cell.is_some_and(|current| current == cell_index) {
            continue;
        }
        if !matches!(app.history.get(cell_index), Some(HistoryCell::Tool(_))) {
            continue;
        }
        if let Some(anchor) = TranscriptScroll::anchor_for(line_meta, idx) {
            app.viewport.transcript_scroll = anchor;
            app.viewport.pending_scroll_delta = 0;
            app.needs_redraw = true;
            return true;
        }
    }

    false
}

pub(crate) fn open_pager_for_selection(app: &mut App) -> bool {
    let Some(text) = selection_to_text(app) else {
        return false;
    };
    let width = app
        .viewport
        .last_transcript_area
        .map(|area| area.width)
        .unwrap_or(80);
    let pager = PagerView::from_text("Selection", &text, width.saturating_sub(2));
    app.view_stack.push(pager);
    true
}

pub(crate) fn open_pager_for_last_message(app: &mut App) -> bool {
    let Some(cell) = app.history.last() else {
        return false;
    };
    let width = app
        .viewport
        .last_transcript_area
        .map(|area| area.width)
        .unwrap_or(80);
    let text = history_cell_to_text(cell, width);
    let pager = PagerView::from_text("Message", &text, width.saturating_sub(2));
    app.view_stack.push(pager);
    true
}

/// Compatibility wrapper for tests that exercise Ctrl+O on a thinking cell.
/// The user-facing Ctrl+O surface is now the turn-scoped Reasoning Detail
/// pager (#v092-reasoning-fix).
#[cfg(test)]
pub(crate) fn open_thinking_pager(app: &mut App) -> bool {
    open_reasoning_detail_pager(app)
}