Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 |
+120
-11
@@ -41,8 +41,10 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::{abbreviate_dir, command_tool};
|
||||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
use maestro_protocol::{
|
||||||
|
BranchInfo, CreateSessionRequest, Project, SessionView, SpawnPreset, WorktreeSpec,
|
||||||
|
};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
@@ -86,9 +88,11 @@ pub enum DraftField {
|
|||||||
/// apply immediately, mirroring the web form's per-click choice.
|
/// apply immediately, mirroring the web form's per-click choice.
|
||||||
WorktreeChoice,
|
WorktreeChoice,
|
||||||
Name,
|
Name,
|
||||||
/// Disclosure collapsing the worktree/branch controls below (#93).
|
/// Disclosure collapsing the optional view and worktree controls below.
|
||||||
/// Closed by default; Enter / Space toggles.
|
/// Closed by default; Enter / Space toggles.
|
||||||
Advanced,
|
Advanced,
|
||||||
|
/// ACP-backed transcript instead of a terminal. Claude Code only.
|
||||||
|
StructuredToggle,
|
||||||
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
/// Worktree on/off for this session (#65). Enter / Space toggles.
|
||||||
WorktreeToggle,
|
WorktreeToggle,
|
||||||
/// New-vs-existing branch source; Left / Right switches.
|
/// New-vs-existing branch source; Left / Right switches.
|
||||||
@@ -251,6 +255,9 @@ pub struct DraftPane {
|
|||||||
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
/// "Advanced options" disclosure (#93). Closed by default — most spawns
|
||||||
/// take the default target.
|
/// take the default target.
|
||||||
pub advanced_collapsed: bool,
|
pub advanced_collapsed: bool,
|
||||||
|
/// ACP-backed structured transcript. Off by default and supported only
|
||||||
|
/// when the resolved command is Claude Code.
|
||||||
|
pub structured_view: bool,
|
||||||
/// Enclosing repository of the chosen target — from the project scan for
|
/// Enclosing repository of the chosen target — from the project scan for
|
||||||
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
/// picked rows, `/api/gitroot` for raw paths. `None` until known.
|
||||||
pub base_repo: Option<PathBuf>,
|
pub base_repo: Option<PathBuf>,
|
||||||
@@ -311,6 +318,7 @@ impl Default for DraftPane {
|
|||||||
name_touched: false,
|
name_touched: false,
|
||||||
worktree_enabled: true,
|
worktree_enabled: true,
|
||||||
advanced_collapsed: true,
|
advanced_collapsed: true,
|
||||||
|
structured_view: false,
|
||||||
base_repo: None,
|
base_repo: None,
|
||||||
probed_cwd: None,
|
probed_cwd: None,
|
||||||
probe_seq: 0,
|
probe_seq: 0,
|
||||||
@@ -348,6 +356,7 @@ impl DraftPane {
|
|||||||
.position(|p| !p.command.is_empty())
|
.position(|p| !p.command.is_empty())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
self.apply_seed_command();
|
self.apply_seed_command();
|
||||||
|
self.clear_unsupported_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Land a seeded command on the tile that runs it: its own preset when the
|
/// Land a seeded command on the tile that runs it: its own preset when the
|
||||||
@@ -663,6 +672,22 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn supports_structured_view(&self) -> bool {
|
||||||
|
command_tool(self.command()) == Some("claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_unsupported_structured_view(&mut self) {
|
||||||
|
if !self.supports_structured_view() {
|
||||||
|
self.structured_view = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_structured_view(&mut self) {
|
||||||
|
if self.supports_structured_view() {
|
||||||
|
self.structured_view = !self.structured_view;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The chosen working directory: a picked project row, else the raw path
|
/// The chosen working directory: a picked project row, else the raw path
|
||||||
/// input when it holds something.
|
/// input when it holds something.
|
||||||
pub fn cwd(&self) -> Option<PathBuf> {
|
pub fn cwd(&self) -> Option<PathBuf> {
|
||||||
@@ -694,7 +719,11 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
let worktree = self.build_worktree()?;
|
let worktree = self.build_worktree()?;
|
||||||
Ok(CreateSessionRequest {
|
Ok(CreateSessionRequest {
|
||||||
view: Default::default(),
|
view: if self.structured_view && self.supports_structured_view() {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
},
|
||||||
cwd,
|
cwd,
|
||||||
command: command.to_string(),
|
command: command.to_string(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
@@ -754,6 +783,9 @@ impl DraftPane {
|
|||||||
order.push(DraftField::Name);
|
order.push(DraftField::Name);
|
||||||
order.push(DraftField::Advanced);
|
order.push(DraftField::Advanced);
|
||||||
if !self.advanced_collapsed {
|
if !self.advanced_collapsed {
|
||||||
|
if self.supports_structured_view() {
|
||||||
|
order.push(DraftField::StructuredToggle);
|
||||||
|
}
|
||||||
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
if !self.cwd_raw_visible && self.selected_group_key.is_some() {
|
||||||
order.push(DraftField::WorktreeChoice);
|
order.push(DraftField::WorktreeChoice);
|
||||||
}
|
}
|
||||||
@@ -795,6 +827,7 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
self.selected_preset =
|
self.selected_preset =
|
||||||
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
((self.selected_preset as i32) + delta).rem_euclid(len as i32) as usize;
|
||||||
|
self.clear_unsupported_structured_view();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step-one boxes surviving the type-to-filter, in display order. A
|
/// Step-one boxes surviving the type-to-filter, in display order. A
|
||||||
@@ -1001,9 +1034,9 @@ impl DraftPane {
|
|||||||
// Two fields keep their own Enter instead: `DirPicker`, whose
|
// Two fields keep their own Enter instead: `DirPicker`, whose
|
||||||
// highlighted row isn't the picked one until a first Enter chooses
|
// highlighted row isn't the picked one until a first Enter chooses
|
||||||
// it (the next Enter, now that it's active, falls through to
|
// it (the next Enter, now that it's active, falls through to
|
||||||
// submit); and the disclosure/toggle pair (`Advanced`,
|
// submit); and the disclosure/checkbox fields, which activate on
|
||||||
// `WorktreeToggle`), which activate on Enter like a button rather
|
// Enter like buttons rather than submitting, matching the web
|
||||||
// than submitting, same as the web form's own `<button>` controls.
|
// form controls.
|
||||||
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
InputKey::Enter if self.focused_field == DraftField::DirPicker => {
|
||||||
let is_other_row = self.selection() >= self.visible_groups().len();
|
let is_other_row = self.selection() >= self.visible_groups().len();
|
||||||
let choose_only =
|
let choose_only =
|
||||||
@@ -1023,6 +1056,10 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
|
InputKey::Enter if self.focused_field == DraftField::StructuredToggle => {
|
||||||
|
self.toggle_structured_view();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
InputKey::Enter => DraftInputResult::Submit,
|
InputKey::Enter => DraftInputResult::Submit,
|
||||||
InputKey::Char {
|
InputKey::Char {
|
||||||
c: ' ',
|
c: ' ',
|
||||||
@@ -1040,6 +1077,14 @@ impl DraftPane {
|
|||||||
self.toggle_worktree();
|
self.toggle_worktree();
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
}
|
}
|
||||||
|
InputKey::Char {
|
||||||
|
c: ' ',
|
||||||
|
ctrl: false,
|
||||||
|
..
|
||||||
|
} if self.focused_field == DraftField::StructuredToggle => {
|
||||||
|
self.toggle_structured_view();
|
||||||
|
DraftInputResult::Consumed
|
||||||
|
}
|
||||||
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
InputKey::Up if self.focused_field == DraftField::DirPicker => {
|
||||||
self.move_selection(-1);
|
self.move_selection(-1);
|
||||||
DraftInputResult::Consumed
|
DraftInputResult::Consumed
|
||||||
@@ -1083,7 +1128,10 @@ impl DraftPane {
|
|||||||
other => {
|
other => {
|
||||||
match self.focused_field {
|
match self.focused_field {
|
||||||
DraftField::Preset | DraftField::Submit => {}
|
DraftField::Preset | DraftField::Submit => {}
|
||||||
DraftField::CustomCommand => self.custom_command.handle_key(other),
|
DraftField::CustomCommand => {
|
||||||
|
self.custom_command.handle_key(other);
|
||||||
|
self.clear_unsupported_structured_view();
|
||||||
|
}
|
||||||
// Typing filters the list; the highlight resets to the top
|
// Typing filters the list; the highlight resets to the top
|
||||||
// so a narrowed search always starts at its best match.
|
// so a narrowed search always starts at its best match.
|
||||||
DraftField::DirPicker => {
|
DraftField::DirPicker => {
|
||||||
@@ -1121,9 +1169,9 @@ impl DraftPane {
|
|||||||
|
|
||||||
let show_custom = self.is_custom();
|
let show_custom = self.is_custom();
|
||||||
let show_raw = self.cwd_raw_visible;
|
let show_raw = self.cwd_raw_visible;
|
||||||
// The worktree choice step, the worktree toggle and its body all stay
|
let show_structured = !self.advanced_collapsed && self.supports_structured_view();
|
||||||
// behind the "Advanced options" disclosure (#93): collapsed hides
|
// Optional view and worktree controls stay behind the "Advanced
|
||||||
// them, regardless of what they'd otherwise show.
|
// options" disclosure (#93), regardless of what they'd otherwise show.
|
||||||
let show_wt_toggle = !self.advanced_collapsed;
|
let show_wt_toggle = !self.advanced_collapsed;
|
||||||
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
let show_wt_body = show_wt_toggle && self.worktree_body_visible();
|
||||||
let show_wt_choice =
|
let show_wt_choice =
|
||||||
@@ -1142,6 +1190,7 @@ impl DraftPane {
|
|||||||
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
let fixed: u16 = 3 /* tiles + dir + name */ + 1 /* advanced header */ + 1 /* spawn */
|
||||||
+ u16::from(show_custom)
|
+ u16::from(show_custom)
|
||||||
+ u16::from(show_raw)
|
+ u16::from(show_raw)
|
||||||
|
+ u16::from(show_structured)
|
||||||
+ u16::from(show_wt_toggle)
|
+ u16::from(show_wt_toggle)
|
||||||
+ u16::from(show_wt_choice) * wt_choice_capacity
|
+ u16::from(show_wt_choice) * wt_choice_capacity
|
||||||
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
+ u16::from(show_wt_body) * (1 + if self.branch_mode == WorktreeBranchMode::New { 1 } else { branch_rows })
|
||||||
@@ -1161,6 +1210,9 @@ impl DraftPane {
|
|||||||
}
|
}
|
||||||
constraints.push(Constraint::Length(1)); // name
|
constraints.push(Constraint::Length(1)); // name
|
||||||
constraints.push(Constraint::Length(1)); // advanced header
|
constraints.push(Constraint::Length(1)); // advanced header
|
||||||
|
if show_structured {
|
||||||
|
constraints.push(Constraint::Length(1));
|
||||||
|
}
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
constraints.push(Constraint::Length(wt_choice_capacity));
|
constraints.push(Constraint::Length(wt_choice_capacity));
|
||||||
}
|
}
|
||||||
@@ -1359,6 +1411,24 @@ impl DraftPane {
|
|||||||
advanced_row,
|
advanced_row,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if show_structured {
|
||||||
|
let row = next_row();
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::raw(format!("{:<GUTTER$}", "view:")),
|
||||||
|
Span::styled(
|
||||||
|
if self.structured_view {
|
||||||
|
"[x] structured view"
|
||||||
|
} else {
|
||||||
|
"[ ] structured view"
|
||||||
|
},
|
||||||
|
field_style(DraftField::StructuredToggle),
|
||||||
|
),
|
||||||
|
])),
|
||||||
|
row,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if show_wt_choice {
|
if show_wt_choice {
|
||||||
// Step two (#96 rule 3): "+ new" first, then the repository's
|
// Step two (#96 rule 3): "+ new" first, then the repository's
|
||||||
// worktrees newest-first.
|
// worktrees newest-first.
|
||||||
@@ -1817,6 +1887,45 @@ mod tests {
|
|||||||
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
assert_eq!(req.cwd, PathBuf::from("/srv/work"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_is_default_off_and_only_visible_for_claude_code() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
advanced_collapsed: false,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
assert!(!draft.structured_view);
|
||||||
|
assert!(draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(rendered.contains("[ ] structured view"), "{rendered}");
|
||||||
|
|
||||||
|
draft.toggle_structured_view();
|
||||||
|
assert!(draft.structured_view);
|
||||||
|
draft.cycle_preset(1);
|
||||||
|
assert_eq!(draft.command(), "opencode");
|
||||||
|
assert!(
|
||||||
|
!draft.structured_view,
|
||||||
|
"a hidden value must not stay checked"
|
||||||
|
);
|
||||||
|
assert!(!draft.field_order().contains(&DraftField::StructuredToggle));
|
||||||
|
let rendered = render_to_string(100, 24, |f| draft.render(f, f.area(), true));
|
||||||
|
assert!(!rendered.contains("structured view"), "{rendered}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_view_request_is_guarded_against_a_stale_command() {
|
||||||
|
let mut draft = DraftPane {
|
||||||
|
picked_cwd: Some(PathBuf::from("/srv/work")),
|
||||||
|
worktree_enabled: false,
|
||||||
|
structured_view: true,
|
||||||
|
..DraftPane::default()
|
||||||
|
};
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Structured);
|
||||||
|
|
||||||
|
draft.selected_preset = 1;
|
||||||
|
assert_eq!(draft.command(), "opencode");
|
||||||
|
assert_eq!(draft.build_request().unwrap().view, SessionView::Terminal);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
fn projects_render_sorted_with_stale_dimmed_and_other_directory_last() {
|
||||||
let mut draft = DraftPane {
|
let mut draft = DraftPane {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const base: SpawnFormValues = {
|
|||||||
command: "claude",
|
command: "claude",
|
||||||
cwd: "/home/user/project",
|
cwd: "/home/user/project",
|
||||||
name: "amber-fox",
|
name: "amber-fox",
|
||||||
|
structuredView: false,
|
||||||
worktreeEnabled: false,
|
worktreeEnabled: false,
|
||||||
worktreeBranch: "",
|
worktreeBranch: "",
|
||||||
worktreeBranchMode: "new",
|
worktreeBranchMode: "new",
|
||||||
@@ -25,6 +26,22 @@ describe("buildSpawnRequest", () => {
|
|||||||
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
expect(res).toEqual({ req: { cwd: base.cwd, command: base.command, metadata: {} } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requests structured view only for Claude Code", () => {
|
||||||
|
expect(buildSpawnRequest({ ...base, structuredView: true }, null)).toEqual({
|
||||||
|
req: {
|
||||||
|
cwd: base.cwd,
|
||||||
|
command: "claude",
|
||||||
|
metadata: { name: "amber-fox" },
|
||||||
|
view: "Structured",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
buildSpawnRequest({ ...base, command: "opencode", structuredView: true }, null),
|
||||||
|
).toEqual({
|
||||||
|
req: { cwd: base.cwd, command: "opencode", metadata: { name: "amber-fox" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
it("attaches a trimmed branch when worktree is enabled, and always cleans up", () => {
|
||||||
const res = buildSpawnRequest(
|
const res = buildSpawnRequest(
|
||||||
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
{ ...base, worktreeEnabled: true, worktreeBranch: " feat/foo " },
|
||||||
|
|||||||
+28
-3
@@ -11,6 +11,7 @@ import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
|||||||
import { parentDir } from "./paths.js";
|
import { parentDir } from "./paths.js";
|
||||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||||
import type { ProjectGroup } from "./projectList.js";
|
import type { ProjectGroup } from "./projectList.js";
|
||||||
|
import { splitCommand } from "./toolIcon.js";
|
||||||
import { abbreviateCwd } from "./types.js";
|
import { abbreviateCwd } from "./types.js";
|
||||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export interface SpawnFormValues {
|
|||||||
command: string;
|
command: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
structuredView: boolean;
|
||||||
worktreeEnabled: boolean;
|
worktreeEnabled: boolean;
|
||||||
worktreeBranch: string;
|
worktreeBranch: string;
|
||||||
worktreeBranchEdited: boolean;
|
worktreeBranchEdited: boolean;
|
||||||
@@ -66,6 +68,10 @@ export function buildSpawnRequest(
|
|||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (values.structuredView && splitCommand(values.command).tool === "claude") {
|
||||||
|
req.view = "Structured";
|
||||||
|
}
|
||||||
|
|
||||||
if (values.worktreeEnabled) {
|
if (values.worktreeEnabled) {
|
||||||
const mode = values.worktreeBranchMode ?? "new";
|
const mode = values.worktreeBranchMode ?? "new";
|
||||||
const branch = values.worktreeBranch.trim();
|
const branch = values.worktreeBranch.trim();
|
||||||
@@ -153,6 +159,10 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
<span class="wt-summary"></span>
|
<span class="wt-summary"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="wt-collapsible" hidden>
|
<div class="wt-collapsible" hidden>
|
||||||
|
<label class="row-inline structured-view" hidden>
|
||||||
|
<input type="checkbox" name="structured_view" />
|
||||||
|
Structured view
|
||||||
|
</label>
|
||||||
<div class="field wt-step" hidden>
|
<div class="field wt-step" hidden>
|
||||||
<span class="field-label">Worktree</span>
|
<span class="field-label">Worktree</span>
|
||||||
<div class="picker wt-choices"></div>
|
<div class="picker wt-choices"></div>
|
||||||
@@ -203,6 +213,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||||
|
const structuredWrap = form.querySelector(".structured-view") as HTMLElement;
|
||||||
|
const structuredView = form.querySelector("input[name=structured_view]") as HTMLInputElement;
|
||||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||||
@@ -248,6 +260,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
|
structuredView: structuredView.checked,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
@@ -317,6 +330,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
} else {
|
} else {
|
||||||
command = preset!.command;
|
command = preset!.command;
|
||||||
}
|
}
|
||||||
|
updateStructuredAvailability();
|
||||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === id);
|
||||||
}
|
}
|
||||||
@@ -354,10 +368,19 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
// operator has already touched (#76).
|
// operator has already touched (#76).
|
||||||
let commandTouched = false;
|
let commandTouched = false;
|
||||||
|
|
||||||
|
function updateStructuredAvailability(): void {
|
||||||
|
const supported = splitCommand(command).tool === "claude";
|
||||||
|
structuredWrap.hidden = !supported;
|
||||||
|
if (!supported) structuredView.checked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredView.addEventListener("change", emit);
|
||||||
|
|
||||||
cmdRaw.addEventListener("input", () => {
|
cmdRaw.addEventListener("input", () => {
|
||||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||||
commandTouched = true;
|
commandTouched = true;
|
||||||
command = cmdRaw.value.trim();
|
command = cmdRaw.value.trim();
|
||||||
|
updateStructuredAvailability();
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -844,10 +867,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
if (target === wtEnabled) {
|
if (target === wtEnabled || target === structuredView) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
wtEnabled.checked = !wtEnabled.checked;
|
const checkbox = target as HTMLInputElement;
|
||||||
wtEnabled.dispatchEvent(new Event("change"));
|
checkbox.checked = !checkbox.checked;
|
||||||
|
checkbox.dispatchEvent(new Event("change"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,6 +909,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
|||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
name: nameInput.value,
|
name: nameInput.value,
|
||||||
|
structuredView: structuredView.checked,
|
||||||
worktreeEnabled: wtEnabled.checked,
|
worktreeEnabled: wtEnabled.checked,
|
||||||
worktreeBranch: branchValue(),
|
worktreeBranch: branchValue(),
|
||||||
worktreeBranchEdited: branchEdited,
|
worktreeBranchEdited: branchEdited,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||||
|
|
||||||
|
function stubApi(): RequestInit[] {
|
||||||
|
const sessionRequests: RequestInit[] = [];
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/api/sessions") && init?.method === "POST") {
|
||||||
|
sessionRequests.push(init);
|
||||||
|
return new Response(JSON.stringify({ id: "s1", command: "claude", cwd: "/srv/work" }), {
|
||||||
|
status: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/api/info")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user" }),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("/api/names")) {
|
||||||
|
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify([]), { status: 200 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return sessionRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("spawn form structured view", () => {
|
||||||
|
let handle: SpawnFormHandle | null = null;
|
||||||
|
|
||||||
|
async function drain(): Promise<void> {
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
handle?.destroy();
|
||||||
|
handle = null;
|
||||||
|
await drain();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is default-off and shown only for the resolved Claude Code command", async () => {
|
||||||
|
stubApi();
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const wrap = handle.el.querySelector<HTMLElement>(".structured-view")!;
|
||||||
|
const checkbox = wrap.querySelector<HTMLInputElement>("input")!;
|
||||||
|
const disclosure = handle.el.querySelector<HTMLElement>(".wt-collapsible")!;
|
||||||
|
expect(disclosure.hidden).toBe(true);
|
||||||
|
handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||||
|
expect(disclosure.hidden).toBe(false);
|
||||||
|
expect(wrap.hidden).toBe(false);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLButtonElement>('[data-id="opencode"]')!.click();
|
||||||
|
expect(wrap.hidden).toBe(true);
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLButtonElement>('[data-id="claude"]')!.click();
|
||||||
|
expect(wrap.hidden).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a checked value when the command changes away from Claude Code", async () => {
|
||||||
|
stubApi();
|
||||||
|
handle = createSpawnForm({ onSuccess() {} });
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
const checkbox = handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!;
|
||||||
|
checkbox.click();
|
||||||
|
expect(checkbox.checked).toBe(true);
|
||||||
|
|
||||||
|
const custom = handle.el.querySelector<HTMLButtonElement>('[data-id="custom"]')!;
|
||||||
|
custom.click();
|
||||||
|
const command = handle.el.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||||
|
command.value = "opencode";
|
||||||
|
command.dispatchEvent(new Event("input"));
|
||||||
|
|
||||||
|
expect(checkbox.closest<HTMLElement>(".structured-view")!.hidden).toBe(true);
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits Structured in the submitted request when selected", async () => {
|
||||||
|
const requests = stubApi();
|
||||||
|
handle = createSpawnForm({
|
||||||
|
seed: { cwd: "/srv/work", cwdRawVisible: true, worktreeEnabled: false },
|
||||||
|
onSuccess() {},
|
||||||
|
});
|
||||||
|
document.body.appendChild(handle.el);
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
handle.el.querySelector<HTMLInputElement>("input[name=structured_view]")!.click();
|
||||||
|
handle.el.requestSubmit();
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect(requests).toHaveLength(1);
|
||||||
|
expect(JSON.parse(String(requests[0].body))).toMatchObject({
|
||||||
|
command: "claude",
|
||||||
|
view: "Structured",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -276,6 +276,7 @@ export interface CreateSessionRequest {
|
|||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
worktree?: WorktreeSpec;
|
worktree?: WorktreeSpec;
|
||||||
|
view?: SessionView;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CatalogEvent =
|
export type CatalogEvent =
|
||||||
|
|||||||
Reference in New Issue
Block a user