Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ npm/dist/

# Wisp runtime data (user-specific, created by `wisp init`)
**/.wisp/sessions/
**/.wisp/settings.toml
wisp.toml

# OS
Expand All @@ -19,3 +20,6 @@ Thumbs.db

.claude
.npm-cache/

# QA-issues-reports
Wisp-QA-issues.txt
1 change: 0 additions & 1 deletion .wisp/settings.toml

This file was deleted.

2 changes: 1 addition & 1 deletion npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ current platform. If the binary is unavailable, build from source in the repo.

```sh
wisp init
wisp run
wisp summon "<your task>"
```
13 changes: 12 additions & 1 deletion npm/scripts/postinstall.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ const arch = ARCH_MAP[process.arch];

if (!plat || !arch) {
warn(`Unsupported platform: ${process.platform}-${process.arch}`);
warn('No binary will be installed — the `wisp` command will not be runnable on this platform.');
warn('Build from source: https://github.com/LKA09/Wisp#build-from-source');
process.exit(0);
}

Expand All @@ -61,7 +63,16 @@ get(downloadUrl, (err, data) => {
warn(`Expected release asset: ${assetName}`);
warn(`Release URL checked: ${downloadUrl}`);
warn('');
warn('The GitHub Release asset may be missing for this version/platform.');
const isHttpError = err.message && /^HTTP \d+/.test(err.message);
const is404 = err.message && err.message.includes('HTTP 404');
if (is404) {
warn('The GitHub Release asset may be missing for this version/platform.');
} else if (isHttpError) {
warn(`HTTP error during download. The release asset may be missing or the release URL is incorrect.`);
} else {
warn('This appears to be a network or access error (not a missing release asset).');
warn('Check your internet connection, firewall, or proxy settings and retry.');
}
warn('');
warn('Build from source (Windows PowerShell):');
warn(' cd wisp');
Expand Down
7 changes: 7 additions & 0 deletions wisp/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ fn parse_execute_command(task: &str, permission_mode: PermissionMode) -> Interac
}

pub fn interactive() {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
eprintln!(
"wisp: interactive TUI requires a terminal. Use `wisp summon \"<task>\"` for non-interactive use."
);
std::process::exit(1);
}
if let Err(e) = crate::tui::run() {
eprintln!("Error starting TUI: {e}");
}
Expand Down
11 changes: 8 additions & 3 deletions wisp/src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,15 @@ pub fn agent_display(name: &str) -> String {

pub fn header(task: &str, branch: &str, mode: &str, instruction_files: usize) {
let rule = heavy_rule();
let subtitle = if mode == "dry-run" {
"preview workflow"
} else {
"implement · patch · review · ship"
};

emit(&rule);
emit(&format!(
" {ACCENT}✦{RESET} {BOLD}{WHITE}Wisp{RESET} {GRAY}—{RESET} implement · patch · review · ship"
" {ACCENT}✦{RESET} {BOLD}{WHITE}Wisp{RESET} {GRAY}—{RESET} {subtitle}"
));
emit(&rule);
emit("");
Expand Down Expand Up @@ -412,15 +417,15 @@ pub fn mode_status(execute_agents: bool) {
{GRAY}— bare tasks invoke agents{RESET}"
));
emit(&format!(
" {GRAY}Use {WHITE}/mode dry-run{GRAY} to switch to preview-only.{RESET}"
" {GRAY}Use {WHITE}wisp mode dry-run{GRAY} or {WHITE}/mode dry-run{GRAY} to switch to preview-only.{RESET}"
));
} else {
emit(&format!(
" {ACCENT}✦{RESET} mode: {WHITE}{BOLD}dry-run{RESET} \
{GRAY}— bare tasks show a preview only (default){RESET}"
));
emit(&format!(
" {GRAY}Use {WHITE}/mode execute{GRAY} to invoke agents for bare tasks.{RESET}"
" {GRAY}Use {WHITE}wisp mode execute{GRAY} or {WHITE}/mode execute{GRAY} to invoke agents for bare tasks.{RESET}"
));
}
emit("");
Expand Down
4 changes: 1 addition & 3 deletions wisp/src/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ pub fn is_protected_path(path: &str, config: &Config) -> bool {
let normalized = normalize_path(path);
config.policy.protected_paths.iter().any(|candidate| {
let candidate = normalize_path(candidate);
normalized == candidate
|| normalized.starts_with(&(candidate.clone() + "/"))
|| normalized.contains(&candidate)
normalized == candidate || normalized.starts_with(&(candidate.clone() + "/"))
}) || matches_sensitive_path(&normalized)
}

Expand Down
15 changes: 13 additions & 2 deletions wisp/src/tui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ impl App {
return;
}
// Skip redundant workflow title
if t.contains("implement · patch · review · ship") {
if t.contains("implement · patch · review · ship") || t.contains("preview workflow") {
return;
}
// Skip session path (too technical for TUI)
Expand Down Expand Up @@ -449,7 +449,18 @@ fn on_submit(input: String, app: &mut App) -> anyhow::Result<()> {
},
InteractiveAction::EnterPasteMode => {
app.push(format!(" > {input}"), LAVENDER);
app.push(" Type your task in the input bar and press Enter.", DIM);
app.push(
" Multi-line tasks: type your task, then end with a trailing command:",
DIM,
);
app.push(" <task text> /run — execute workflow", DIM);
app.push(" <task text> /dry — dry-run preview", DIM);
app.push(" <task text> /claude — run Claude directly", DIM);
app.push(" <task text> /codex — run Codex directly", DIM);
app.push(
" Or type a task alone and press Enter (respects current mode).",
DIM,
);
}
InteractiveAction::ModeAction { arg } => {
app.push(format!(" > {input}"), LAVENDER);
Expand Down
90 changes: 82 additions & 8 deletions wisp/src/workflow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,15 @@ pub fn summon(args: SummonArgs) -> Result<()> {
// ── Step 1: implement (always once) ───────────────────────────────────────
step += 1;
let impl_prompt = build_implement_prompt(&normalized_task_en, &args.task, &instructions_text);
let impl_display_role = display_role_label("implement", args.execute_agents);
run_workflow_step(
&config,
&session,
&args,
&cwd,
config.workflow.implementer.as_str(),
"implement",
&impl_display_role,
step,
total_steps,
"prompts/implementer.en.md",
Expand Down Expand Up @@ -141,7 +143,7 @@ pub fn summon(args: SummonArgs) -> Result<()> {
display::wisp_note(&handoff_note(
handoff_from,
config.workflow.patcher.as_str(),
"patch",
&display_role_label("patch", args.execute_agents),
&args.lang,
));

Expand All @@ -153,13 +155,15 @@ pub fn summon(args: SummonArgs) -> Result<()> {
"patch".to_string()
};
let patch_prompt = build_patch_prompt(&normalized_task_en, &args.task, &instructions_text);
let patch_display_role = display_role_label(&patch_role, args.execute_agents);
run_workflow_step(
&config,
&session,
&args,
&cwd,
config.workflow.patcher.as_str(),
&patch_role,
&patch_display_role,
step,
total_steps,
&format!("prompts/patcher{suffix}.en.md"),
Expand All @@ -172,7 +176,7 @@ pub fn summon(args: SummonArgs) -> Result<()> {
display::wisp_note(&handoff_note(
config.workflow.patcher.as_str(),
config.workflow.reviewer.as_str(),
"review",
&display_role_label("review", args.execute_agents),
&args.lang,
));

Expand All @@ -183,6 +187,7 @@ pub fn summon(args: SummonArgs) -> Result<()> {
} else {
"review".to_string()
};
let review_display_role = display_role_label(&review_role, args.execute_agents);
let review_prompt = build_review_prompt(&normalized_task_en, &args.task);
let review_output = run_workflow_step(
&config,
Expand All @@ -191,6 +196,7 @@ pub fn summon(args: SummonArgs) -> Result<()> {
&cwd,
config.workflow.reviewer.as_str(),
&review_role,
&review_display_role,
step,
total_steps,
&format!("prompts/reviewer{suffix}.en.md"),
Expand Down Expand Up @@ -256,19 +262,21 @@ pub fn summon(args: SummonArgs) -> Result<()> {
display::wisp_note(&handoff_note(
config.workflow.reviewer.as_str(),
config.workflow.shipper.as_str(),
"ship",
&display_role_label("ship", args.execute_agents),
&args.lang,
));

step += 1;
let ship_prompt = build_ship_prompt(&normalized_task_en, &args.task);
let ship_display_role = display_role_label("ship", args.execute_agents);
run_workflow_step(
&config,
&session,
&args,
&cwd,
config.workflow.shipper.as_str(),
"ship",
&ship_display_role,
step,
total_steps,
"prompts/shipper.en.md",
Expand Down Expand Up @@ -403,7 +411,25 @@ pub fn run_single_agent(args: SingleAgentArgs) -> Result<()> {
write_step_diffs(&session, "direct", &before_step, &after_step)?;

match handle_policy_violations(&violations, &config, &args.lang, &args.agent, "direct")? {
true => display::agent_end(&args.agent, output.status == 0),
true => {
let succeeded = output.status == 0 || !args.execute_agents;
display::agent_end(&args.agent, succeeded);
if args.execute_agents && output.status != 0 {
bail!(msg(
&args.lang,
&format!(
"{} failed with exit code {}.",
display::agent_display(&args.agent),
output.status
),
&format!(
"{}이(가) 종료 코드 {}로 실패했습니다.",
display::agent_display(&args.agent),
output.status
),
));
}
}
false => {
display::agent_end(&args.agent, false);
bail!(format_policy_violation_error(
Expand Down Expand Up @@ -438,6 +464,7 @@ fn run_workflow_step(
cwd: &std::path::Path,
agent: &str,
role: &str,
display_role: &str,
step_num: usize,
total_steps: usize,
prompt_file: &str,
Expand All @@ -447,7 +474,7 @@ fn run_workflow_step(
) -> Result<AgentOutput> {
session.write(prompt_file, prompt)?;

display::agent_start(agent, role, step_num, total_steps);
display::agent_start(agent, display_role, step_num, total_steps);
let cfg = config
.agents
.get(agent)
Expand Down Expand Up @@ -516,7 +543,25 @@ fn run_workflow_step(
write_step_diffs(session, role, &before_step, &after_step)?;

match handle_policy_violations(&violations, config, &args.lang, agent, role)? {
true => display::agent_end(agent, output.status == 0),
true => {
let succeeded = output.status == 0 || !args.execute_agents;
display::agent_end(agent, succeeded);
if args.execute_agents && output.status != 0 {
bail!(msg(
&args.lang,
&format!(
"{} ({role}) failed with exit code {}.",
display::agent_display(agent),
output.status
),
&format!(
"{} ({role})이(가) 종료 코드 {}로 실패했습니다.",
display::agent_display(agent),
output.status
),
));
}
}
false => {
display::agent_end(agent, false);
bail!(format_policy_violation_error(
Expand Down Expand Up @@ -881,6 +926,31 @@ fn handoff_note(from: &str, to: &str, role: &str, lang: &Language) -> String {
}
}

fn display_role_label(role: &str, execute_agents: bool) -> String {
if execute_agents {
return role.to_string();
}

let suffix = role
.find('[')
.map(|idx| format!(" {}", role[idx..].trim()))
.unwrap_or_default();

let base = if role.starts_with("implement") {
"task analysis"
} else if role.starts_with("patch") {
"change preview"
} else if role.starts_with("review") {
"review check"
} else if role.starts_with("ship") {
"summary"
} else {
role
};

format!("{base}{suffix}")
}

// ── Task normalization ─────────────────────────────────────────────────────────

fn normalize_task_en(task: &str, lang: &Language) -> String {
Expand Down Expand Up @@ -1099,7 +1169,9 @@ fn finalize_workflow_summary(
## Security Note\n\n\
Wisp is not a security sandbox. Agents run with your full user permissions. \
The policy layer blocks specific commands and paths configured in wisp.toml, \
but cannot prevent all unsafe actions. Review agent output before approving commits.\n",
but cannot prevent all unsafe actions. Review agent output before approving commits.\n\n\
Session logs in `.wisp/sessions/` contain task text, prompts, instructions, \
and git diffs verbatim. Review and delete sessions that contain sensitive data.\n",
session.path().display(),
instructions.files.len(),
instructions.total_bytes,
Expand Down Expand Up @@ -1137,7 +1209,9 @@ fn finalize_single_agent_summary(
- Session: {}\n\
- Instructions loaded: {} ({} bytes{})\n\n\
## Security Note\n\n\
Wisp is not a security sandbox. Agents run with your full user permissions.\n",
Wisp is not a security sandbox. Agents run with your full user permissions.\n\
Session logs in `.wisp/sessions/` contain task text, prompts, and git diffs verbatim. \
Review and delete sessions that contain sensitive data.\n",
session.path().display(),
instructions.files.len(),
instructions.total_bytes,
Expand Down
Loading