Skip to content
Open
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,10 @@ Code map:
│ ├── dotmatrix-hooks.ts
│ └── stream.ts
├── lib
│ ├── appearance.ts
│ ├── fileIcon.ts
│ ├── ipc.ts
│ ├── language.ts
│ ├── models.ts
│ ├── monacoTheme.ts
│ └── recents.ts
31 changes: 31 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ tracing-subscriber = { workspace = true }
url = { workspace = true }
portable-pty = "0.9.0"
reqwest = { workspace = true }
which = "7"

[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6.4"
Expand Down
1 change: 1 addition & 0 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"windows": ["main", "sinew-window-*"],
"permissions": [
"core:default",
"core:webview:allow-set-webview-zoom",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/gen/schemas/capabilities.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Main desktop capability","local":true,"windows":["main","sinew-window-*"],"permissions":["core:default","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","dialog:default","updater:default"]}}
{"default":{"identifier":"default","description":"Main desktop capability","local":true,"windows":["main","sinew-window-*"],"permissions":["core:default","core:webview:allow-set-webview-zoom","core:window:allow-close","core:window:allow-minimize","core:window:allow-start-dragging","core:window:allow-toggle-maximize","dialog:default","updater:default"]}}
149 changes: 131 additions & 18 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,39 +204,151 @@ pub fn run() {
install_macos_dock_menu(app.handle());
}

#[cfg(not(target_os = "windows"))]
#[cfg(unix)]
{
let handle = app.handle();
let menu = tauri::menu::Menu::default(handle)?;
let new_conversation_item = tauri::menu::MenuItemBuilder::with_id(
NEW_CONVERSATION_MENU_ID,
"New Conversation",
)
.accelerator("CmdOrCtrl+N")
.build(handle)?;
let new_window_item =
tauri::menu::MenuItemBuilder::with_id(NEW_WINDOW_MENU_ID, "New Window")
.accelerator("CmdOrCtrl+Shift+N")
.build(handle)?;
let file_menu = tauri::menu::SubmenuBuilder::new(handle, "File")
.item(&new_window_item)
let open_workspace_item = tauri::menu::MenuItemBuilder::with_id(
OPEN_WORKSPACE_MENU_ID,
"Open Workspace…",
)
.accelerator("CmdOrCtrl+O")
.build(handle)?;
let settings_item =
tauri::menu::MenuItemBuilder::with_id(SETTINGS_OPEN_MENU_ID, "Settings…")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
let terminal_open_item = tauri::menu::MenuItemBuilder::with_id(
TERMINAL_OPEN_MENU_ID,
"Open Terminal",
)
.accelerator("CmdOrCtrl+`")
.build(handle)?;
let edit_menu = tauri::menu::SubmenuBuilder::new(handle, "Edit")
.undo()
.redo()
.separator()
.cut()
.copy()
.paste()
.select_all()
.build()?;
let terminal_menu = tauri::menu::SubmenuBuilder::new(handle, "Terminal")
.text(TERMINAL_OPEN_MENU_ID, "Open Terminal")
.item(&terminal_open_item)
.build()?;
let window_menu = tauri::menu::SubmenuBuilder::new(handle, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?;
menu.append(&file_menu)?;
menu.append(&terminal_menu)?;

#[cfg(target_os = "macos")]
let menu = {
// macOS convention: Settings + About live in the app
// (Sinew) submenu, not in File. Native About dialog
// gets the description / GitHub link from the
// bundled metadata.
let about_metadata = tauri::menu::AboutMetadataBuilder::new()
.name(Some("Sinew"))
.version(Some(env!("CARGO_PKG_VERSION")))
.copyright(Some("MIT — github.com/Paseru/sinew"))
.website(Some("https://github.com/Paseru/sinew"))
.website_label(Some("github.com/Paseru/sinew"))
.comments(Some(concat!(
"Sinew is a flexible AI coding harness. ",
"You shape it: tweak the description of every tool, turn the ",
"ones you don't need off, and the assistant only sees what ",
"you keep.\n\n",
"Run it minimal with a couple of tools, or unlock the full ",
"set: shell, search, MCP, web, images, sub-agents. ",
"Multi-provider by default."
)))
.build();
let app_menu = tauri::menu::SubmenuBuilder::new(handle, "Sinew")
.about(Some(about_metadata))
.separator()
.item(&settings_item)
.separator()
.services()
.separator()
.hide()
.hide_others()
.show_all()
.separator()
.quit()
.build()?;
let file_menu = tauri::menu::SubmenuBuilder::new(handle, "File")
.item(&new_conversation_item)
.item(&new_window_item)
.separator()
.item(&open_workspace_item)
.separator()
.close_window()
.build()?;
tauri::menu::MenuBuilder::new(handle)
.item(&app_menu)
.item(&file_menu)
.item(&edit_menu)
.item(&terminal_menu)
.item(&window_menu)
.build()?
};

#[cfg(not(target_os = "macos"))]
let menu = {
// Linux has no app submenu, so Settings lives at the
// bottom of the File menu (matches GTK app convention).
let file_menu = tauri::menu::SubmenuBuilder::new(handle, "File")
.item(&new_conversation_item)
.item(&new_window_item)
.separator()
.item(&open_workspace_item)
.separator()
.item(&settings_item)
.separator()
.close_window()
.build()?;
tauri::menu::MenuBuilder::new(handle)
.item(&file_menu)
.item(&edit_menu)
.item(&terminal_menu)
.item(&window_menu)
.build()?
};

app.set_menu(menu)?;
}
Ok(())
})
.on_menu_event(|app, event| {
if event.id() == NEW_WINDOW_MENU_ID {
let id = event.id();
if id == NEW_WINDOW_MENU_ID {
create_new_window_detached(app);
} else if event.id() == TERMINAL_OPEN_MENU_ID {
let focused = app
.webview_windows()
.into_values()
.find(|window| window.is_focused().unwrap_or(false));
if let Some(window) = focused {
let _ = window.emit(TERMINAL_OPEN_EVENT_NAME, ());
} else {
let _ = app.emit(TERMINAL_OPEN_EVENT_NAME, ());
}
return;
}
let event_name: Option<&'static str> = if id == TERMINAL_OPEN_MENU_ID {
Some(TERMINAL_OPEN_EVENT_NAME)
} else if id == SETTINGS_OPEN_MENU_ID {
Some(SETTINGS_OPEN_EVENT_NAME)
} else if id == NEW_CONVERSATION_MENU_ID {
Some(NEW_CONVERSATION_EVENT_NAME)
} else if id == OPEN_WORKSPACE_MENU_ID {
Some(OPEN_WORKSPACE_EVENT_NAME)
} else {
None
};
if let Some(name) = event_name {
let _ = app.emit(name, ());
}
})
.manage(state)
Expand Down Expand Up @@ -323,6 +435,7 @@ pub fn run() {
terminal::write_terminal,
terminal::resize_terminal,
terminal::kill_terminal,
terminal::list_terminal_shells,
updater::updater_check,
updater::updater_download_and_install,
updater::updater_restart,
Expand Down
9 changes: 9 additions & 0 deletions src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,15 @@ pub(super) struct TerminalSpawnInput {
pub(super) pixel_width: u16,
#[serde(default)]
pub(super) pixel_height: u16,
#[serde(default)]
pub(super) shell: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct TerminalShellOption {
pub(super) label: String,
pub(super) path: String,
}

#[derive(Debug, Serialize)]
Expand Down
6 changes: 6 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ pub(super) const TERMINAL_OPEN_EVENT_NAME: &str = "terminal-open-requested";
pub(super) const ACTIVE_TURNS_EVENT_NAME: &str = "active-turns-changed";
pub(super) const TERMINAL_OPEN_MENU_ID: &str = "terminal-open";
pub(super) const NEW_WINDOW_MENU_ID: &str = "new-window";
pub(super) const SETTINGS_OPEN_MENU_ID: &str = "settings-open";
pub(super) const SETTINGS_OPEN_EVENT_NAME: &str = "settings-open-requested";
pub(super) const NEW_CONVERSATION_MENU_ID: &str = "new-conversation";
pub(super) const NEW_CONVERSATION_EVENT_NAME: &str = "new-conversation-requested";
pub(super) const OPEN_WORKSPACE_MENU_ID: &str = "open-workspace";
pub(super) const OPEN_WORKSPACE_EVENT_NAME: &str = "open-workspace-requested";
pub(super) const NEW_WINDOW_LABEL_PREFIX: &str = "sinew-window";
pub(super) const NEW_WINDOW_URL: &str = "index.html?newWindow=1";
pub(super) const MAX_ATTACHMENT_BYTES: usize = 128 * 1024;
Expand Down
51 changes: 50 additions & 1 deletion src-tauri/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ pub(super) async fn spawn_terminal(
))
.map_err(error_to_string)?;

let mut command = default_terminal_command();
let mut command = terminal_command_for(input.shell.as_deref());
command.cwd(workspace_root.as_os_str());
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
Expand Down Expand Up @@ -107,6 +107,55 @@ fn default_terminal_command() -> CommandBuilder {
}
}

fn terminal_command_for(shell: Option<&str>) -> CommandBuilder {
let Some(path) = shell.map(str::trim).filter(|s| !s.is_empty()) else {
return default_terminal_command();
};
let mut command = CommandBuilder::new(path);
let lower = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(path)
.to_ascii_lowercase();
if lower.starts_with("powershell") || lower.starts_with("pwsh") {
command.arg("-NoLogo");
command.arg("-NoProfile");
command.arg("-ExecutionPolicy");
command.arg("Bypass");
}
command
}

#[tauri::command]
pub(super) fn list_terminal_shells() -> Vec<TerminalShellOption> {
let candidates: &[(&str, &str)] = if cfg!(windows) {
&[
("PowerShell Core", "pwsh.exe"),
("Windows PowerShell", "powershell.exe"),
("Command Prompt", "cmd.exe"),
("WSL", "wsl.exe"),
("Git Bash", "bash.exe"),
]
} else {
&[
("zsh", "zsh"),
("bash", "bash"),
("fish", "fish"),
("nu", "nu"),
("sh", "sh"),
]
};
candidates
.iter()
.filter_map(|(label, exe)| {
which::which(exe).ok().map(|path| TerminalShellOption {
label: (*label).to_string(),
path: path.display().to_string(),
})
})
.collect()
}

#[tauri::command]
pub(super) async fn write_terminal(
state: State<'_, DesktopState>,
Expand Down
Loading