mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(accumulator): desktop Accumulator settings tab behind preview flag
Adds an 'accumulator' preview feature (off by default) that reveals a Settings > Personal > Accumulator control plane. The panel is a thin shell over the bundled buzz CLI: a new run_folds_cli Tauri command executes 'buzz folds <verb> ...' as the desktop identity against the active workspace relay, with the verb allow-listed to the folds group, bounded args, drained pipes, and a hard timeout (longer for 'run', which invokes a model). Nonzero exits return as data so the UI can show the CLI's own error message. The panel lists folds, prices a run via the zero-spend preflight (Run only unlocks after Preflight prices the exact pending set), shows the latest artifact, and points at the CLI for creation and automation - fold state itself never lives in the desktop. Mirrors kinds 30640/4640 into shared/constants/kinds.ts and stubs run_folds_cli in the e2e mock bridge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Riley Crane <rileycrane@squareup.com>
This commit is contained in:
208
desktop/src-tauri/src/commands/folds_exec.rs
Normal file
208
desktop/src-tauri/src/commands/folds_exec.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
//! Tauri command backing the Accumulator settings panel.
|
||||
//!
|
||||
//! The panel is a thin control plane over the bundled `buzz` CLI: every
|
||||
//! action runs `buzz folds <verb> …` with the desktop identity's key and the
|
||||
//! active workspace relay, and returns the CLI's output verbatim for the UI
|
||||
//! to render. All fold state lives on the relay as the user's own signed
|
||||
//! events — this command holds none.
|
||||
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use nostr::ToBech32;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::app_state::AppState;
|
||||
use crate::managed_agents::resolve_command;
|
||||
|
||||
/// Verbs the panel may pass through. Everything runs under the `folds`
|
||||
/// group (prepended here, never caller-supplied), so the desktop identity's
|
||||
/// key is only ever handed to the accumulator control plane.
|
||||
const ALLOWED_VERBS: &[&str] = &[
|
||||
"set", "list", "get", "delete", "estimate", "run", "artifact", "share",
|
||||
];
|
||||
|
||||
const MAX_ARGS: usize = 64;
|
||||
const MAX_ARG_LEN: usize = 16 * 1024;
|
||||
|
||||
/// `folds run` invokes a model subprocess (the fold runner's own timeout is
|
||||
/// 600s); every other verb is relay I/O only.
|
||||
const RUN_TIMEOUT: Duration = Duration::from_secs(660);
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FoldsCliOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: i32,
|
||||
}
|
||||
|
||||
fn validate_args(args: &[String]) -> Result<(), String> {
|
||||
let Some(verb) = args.first() else {
|
||||
return Err("folds command requires a verb".to_string());
|
||||
};
|
||||
if !ALLOWED_VERBS.contains(&verb.as_str()) {
|
||||
return Err(format!("unsupported folds verb: {verb}"));
|
||||
}
|
||||
if args.len() > MAX_ARGS {
|
||||
return Err(format!("too many arguments ({} > {MAX_ARGS})", args.len()));
|
||||
}
|
||||
for arg in args {
|
||||
if arg.len() > MAX_ARG_LEN {
|
||||
return Err(format!(
|
||||
"argument too long ({} > {MAX_ARG_LEN} bytes)",
|
||||
arg.len()
|
||||
));
|
||||
}
|
||||
if arg.contains('\0') {
|
||||
return Err("argument contains a NUL byte".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the bundled `buzz` CLI: exe-sibling sidecar first (DMG install),
|
||||
/// then the general command resolver (dev builds, PATH installs).
|
||||
fn resolve_buzz_cli() -> Result<std::path::PathBuf, String> {
|
||||
std::env::current_exe()
|
||||
.map(|path| path.with_file_name(format!("buzz{}", std::env::consts::EXE_SUFFIX)))
|
||||
.ok()
|
||||
.filter(|path| path.exists())
|
||||
.or_else(|| resolve_command("buzz"))
|
||||
.ok_or_else(|| "buzz CLI not found".to_string())
|
||||
}
|
||||
|
||||
fn read_pipe_lossy(pipe: Option<impl Read>) -> String {
|
||||
let Some(mut pipe) = pipe else {
|
||||
return String::new();
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
let _ = pipe.read_to_end(&mut bytes);
|
||||
String::from_utf8_lossy(&bytes).to_string()
|
||||
}
|
||||
|
||||
fn run_folds_cli_blocking(
|
||||
args: Vec<String>,
|
||||
nsec: String,
|
||||
relay_url: String,
|
||||
) -> Result<FoldsCliOutput, String> {
|
||||
validate_args(&args)?;
|
||||
let cli_path = resolve_buzz_cli()?;
|
||||
let timeout = if args[0] == "run" {
|
||||
RUN_TIMEOUT
|
||||
} else {
|
||||
QUERY_TIMEOUT
|
||||
};
|
||||
|
||||
let mut command = Command::new(&cli_path);
|
||||
command.arg("folds").args(&args);
|
||||
command.env("BUZZ_PRIVATE_KEY", &nsec);
|
||||
command.env("BUZZ_RELAY_URL", &relay_url);
|
||||
// The fold runner shells out to the user's model CLI (`claude` by
|
||||
// default); the augmented PATH makes that resolvable from a DMG launch.
|
||||
if let Some(path) = crate::managed_agents::readiness::cli_probe::augmented_path() {
|
||||
command.env("PATH", path);
|
||||
}
|
||||
command.stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped());
|
||||
command.stderr(Stdio::piped());
|
||||
crate::util::configure_no_window(&mut command);
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to run buzz folds: {error}"))?;
|
||||
|
||||
// Drain the pipes on background threads so a chatty run can't deadlock
|
||||
// on a full pipe while we poll for exit below.
|
||||
let stdout_pipe = child.stdout.take();
|
||||
let stderr_pipe = child.stderr.take();
|
||||
let stdout_thread = std::thread::spawn(move || read_pipe_lossy(stdout_pipe));
|
||||
let stderr_thread = std::thread::spawn(move || read_pipe_lossy(stderr_pipe));
|
||||
|
||||
let started = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) => {
|
||||
if started.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = stdout_thread.join();
|
||||
let _ = stderr_thread.join();
|
||||
return Err(format!("buzz folds timed out after {}s", timeout.as_secs()));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("failed to wait for buzz folds: {error}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(FoldsCliOutput {
|
||||
stdout: stdout_thread.join().unwrap_or_default(),
|
||||
stderr: stderr_thread.join().unwrap_or_default(),
|
||||
exit_code: status.code().unwrap_or(-1),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `buzz folds <args…>` as the desktop identity against the active
|
||||
/// workspace relay. Nonzero exits are returned as data (the CLI's stderr
|
||||
/// carries the actionable message); `Err` is reserved for launch failures,
|
||||
/// timeouts, and validation.
|
||||
#[tauri::command]
|
||||
pub async fn run_folds_cli(
|
||||
args: Vec<String>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<FoldsCliOutput, String> {
|
||||
validate_args(&args)?;
|
||||
let nsec = state
|
||||
.signing_keys()?
|
||||
.secret_key()
|
||||
.to_bech32()
|
||||
.map_err(|error| format!("failed to encode signing key: {error}"))?;
|
||||
let relay_url = crate::relay::relay_ws_url_with_override(&state);
|
||||
tokio::task::spawn_blocking(move || run_folds_cli_blocking(args, nsec, relay_url))
|
||||
.await
|
||||
.map_err(|error| format!("folds task failed: {error}"))?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_and_unknown_verbs() {
|
||||
assert!(validate_args(&[]).is_err());
|
||||
assert!(validate_args(&args(&["frobnicate"])).is_err());
|
||||
// Group escape attempts are just unknown verbs.
|
||||
assert!(validate_args(&args(&["--help"])).is_err());
|
||||
assert!(validate_args(&args(&["mem"])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_every_folds_verb() {
|
||||
for verb in ALLOWED_VERBS {
|
||||
assert!(validate_args(&args(&[verb])).is_ok(), "verb {verb}");
|
||||
}
|
||||
assert!(validate_args(&args(&["estimate", "my-fold", "--limit", "50"])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_and_nul_arguments() {
|
||||
assert!(validate_args(&args(&["run", &"x".repeat(MAX_ARG_LEN + 1)])).is_err());
|
||||
assert!(validate_args(&args(&["get", "bad\0name"])).is_err());
|
||||
let mut many = vec!["list".to_string()];
|
||||
many.extend(std::iter::repeat_n("x".to_string(), MAX_ARGS));
|
||||
assert!(validate_args(&many).is_err());
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ mod clipboard;
|
||||
mod dms;
|
||||
mod engrams;
|
||||
mod export_util;
|
||||
mod folds_exec;
|
||||
mod global_agent_config;
|
||||
mod identity;
|
||||
mod identity_archive;
|
||||
@@ -94,6 +95,7 @@ pub use channels::*;
|
||||
pub use clipboard::*;
|
||||
pub use dms::*;
|
||||
pub use engrams::*;
|
||||
pub use folds_exec::*;
|
||||
pub use global_agent_config::*;
|
||||
pub use identity::*;
|
||||
pub use identity_archive::*;
|
||||
|
||||
@@ -839,6 +839,7 @@ pub fn run() {
|
||||
fetch_join_policy,
|
||||
set_prevent_sleep_active,
|
||||
get_agent_memory,
|
||||
run_folds_cli,
|
||||
relay_reconnect_hook,
|
||||
relay_reconnect_hook_configured,
|
||||
observer_archive_default_enabled,
|
||||
|
||||
252
desktop/src/features/accumulator/ui/AccumulatorSettingsCard.tsx
Normal file
252
desktop/src/features/accumulator/ui/AccumulatorSettingsCard.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { runFoldsCli, runFoldsCliJson } from "@/shared/api/tauriFolds";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
SettingsOptionGroup,
|
||||
SettingsOptionGroupList,
|
||||
SettingsOptionRow,
|
||||
} from "@/features/settings/ui/SettingsOptionGroup";
|
||||
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
|
||||
|
||||
// JSON shapes printed by the `buzz folds` CLI (crates/buzz-cli/commands/folds.rs).
|
||||
type FoldSummary = {
|
||||
fold: string;
|
||||
selection: string;
|
||||
schema: string;
|
||||
model: string;
|
||||
latest_version: number | null;
|
||||
covered_signals: number;
|
||||
coverage_until: number | null;
|
||||
};
|
||||
|
||||
type FoldPlan = {
|
||||
fold: string;
|
||||
action: "cached" | "stalled" | "ready" | "run";
|
||||
note?: string;
|
||||
reason?: string;
|
||||
version?: number;
|
||||
pending_signals?: number;
|
||||
would_show?: number;
|
||||
truncated_to_budget?: boolean;
|
||||
est_input_tokens?: number;
|
||||
est_cost_usd?: number | null;
|
||||
next_version?: number;
|
||||
shown_signals?: number;
|
||||
coverage_until?: number | null;
|
||||
};
|
||||
|
||||
const foldsListQueryKey = ["accumulator", "folds"] as const;
|
||||
|
||||
function formatTimestamp(seconds: number | null | undefined): string {
|
||||
if (!seconds) return "never";
|
||||
return new Date(seconds * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function formatCost(costUsd: number | null | undefined): string {
|
||||
if (costUsd === null || costUsd === undefined) return "unknown cost";
|
||||
return `~$${costUsd.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function planSummary(plan: FoldPlan): string {
|
||||
switch (plan.action) {
|
||||
case "cached":
|
||||
return `Up to date — v${plan.version ?? "?"} already covers everything new.`;
|
||||
case "stalled":
|
||||
return `Stalled: ${plan.reason ?? "cannot fit the pending events"} (${plan.pending_signals ?? 0} pending).`;
|
||||
case "ready":
|
||||
return `${plan.pending_signals ?? 0} new signal(s); would fold ${plan.would_show ?? 0} into v${plan.next_version ?? "?"} for ${formatCost(plan.est_cost_usd)} (${plan.est_input_tokens ?? "?"} input tokens${plan.truncated_to_budget ? ", chunked to fit the context budget" : ""}).`;
|
||||
case "run":
|
||||
return `Folded ${plan.shown_signals ?? 0} signal(s) into v${plan.version ?? "?"} (coverage through ${formatTimestamp(plan.coverage_until)}).`;
|
||||
}
|
||||
}
|
||||
|
||||
function FoldRow({ fold }: { fold: FoldSummary }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [plan, setPlan] = React.useState<FoldPlan | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [showArtifact, setShowArtifact] = React.useState(false);
|
||||
|
||||
const artifactQuery = useQuery({
|
||||
queryKey: ["accumulator", "artifact", fold.fold],
|
||||
queryFn: () => runFoldsCli(["artifact", fold.fold, "--raw"]),
|
||||
enabled: showArtifact,
|
||||
});
|
||||
|
||||
const estimateMutation = useMutation({
|
||||
mutationFn: () => runFoldsCliJson<FoldPlan>(["estimate", fold.fold]),
|
||||
onSuccess: (result) => {
|
||||
setPlan(result);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError: Error) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: () => runFoldsCliJson<FoldPlan>(["run", fold.fold]),
|
||||
onSuccess: async (result) => {
|
||||
setPlan(result);
|
||||
setError(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: foldsListQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["accumulator", "artifact", fold.fold],
|
||||
}),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError: Error) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const busy = estimateMutation.isPending || runMutation.isPending;
|
||||
// Priced-before-spend: Run only unlocks after a preflight priced this
|
||||
// exact pending set. Anything that changes the set re-requires preflight.
|
||||
const runReady = plan?.action === "ready" && !busy;
|
||||
|
||||
return (
|
||||
<SettingsOptionRow data-testid={`accumulator-fold-${fold.fold}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{fold.fold}</p>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
{`${fold.selection} · ${fold.schema} · ${fold.model} · ${
|
||||
fold.latest_version
|
||||
? `v${fold.latest_version}, covered through ${formatTimestamp(fold.coverage_until)}`
|
||||
: "never run"
|
||||
}`}
|
||||
</p>
|
||||
{plan ? (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{planSummary(plan)}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="mt-1 text-sm text-destructive">{error}</p>
|
||||
) : null}
|
||||
{showArtifact ? (
|
||||
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap rounded-md border border-border/50 bg-muted/30 p-3 text-xs">
|
||||
{artifactQuery.isPending
|
||||
? "Loading artifact…"
|
||||
: artifactQuery.isError
|
||||
? String(artifactQuery.error)
|
||||
: artifactQuery.data.exitCode === 0
|
||||
? artifactQuery.data.stdout
|
||||
: artifactQuery.data.stderr.trim() ||
|
||||
"This fold has no artifact yet."}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
data-testid={`accumulator-artifact-${fold.fold}`}
|
||||
onClick={() => setShowArtifact((visible) => !visible)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
{showArtifact ? "Hide digest" : "View digest"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`accumulator-estimate-${fold.fold}`}
|
||||
disabled={busy}
|
||||
onClick={() => estimateMutation.mutate()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{estimateMutation.isPending ? "Pricing…" : "Preflight"}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid={`accumulator-run-${fold.fold}`}
|
||||
disabled={!runReady}
|
||||
onClick={() => runMutation.mutate()}
|
||||
size="sm"
|
||||
>
|
||||
{runMutation.isPending
|
||||
? "Folding…"
|
||||
: plan?.action === "ready"
|
||||
? `Run ${formatCost(plan.est_cost_usd)}`
|
||||
: "Run"}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsOptionRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccumulatorSettingsCard() {
|
||||
const foldsQuery = useQuery({
|
||||
queryKey: foldsListQueryKey,
|
||||
queryFn: () => runFoldsCliJson<FoldSummary[]>(["list"]),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-accumulator">
|
||||
<SettingsSectionHeader
|
||||
action={
|
||||
<Button
|
||||
data-testid="accumulator-refresh"
|
||||
disabled={foldsQuery.isFetching}
|
||||
onClick={() => void foldsQuery.refetch()}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw
|
||||
className={foldsQuery.isFetching ? "animate-spin" : undefined}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
title="Accumulator"
|
||||
description="Standing digests over saved relay selections. Each fold keeps one always-current artifact; preflight prices a run exactly before anything is spent."
|
||||
/>
|
||||
<SettingsOptionGroupList>
|
||||
<SettingsOptionGroup title="Folds">
|
||||
{foldsQuery.isPending ? (
|
||||
<SettingsOptionRow>
|
||||
<p className="text-sm text-muted-foreground">Loading folds…</p>
|
||||
</SettingsOptionRow>
|
||||
) : foldsQuery.isError ? (
|
||||
<SettingsOptionRow>
|
||||
<p className="text-sm text-destructive">
|
||||
{String(foldsQuery.error)}
|
||||
</p>
|
||||
</SettingsOptionRow>
|
||||
) : foldsQuery.data.length === 0 ? (
|
||||
<SettingsOptionRow data-testid="accumulator-empty">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">No folds yet</p>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
Create one from a terminal, then manage it here:
|
||||
</p>
|
||||
<pre className="mt-2 overflow-x-auto rounded-md border border-border/50 bg-muted/30 p-3 text-xs">
|
||||
{"buzz folds set my-channel-digest --channel <channel-uuid>"}
|
||||
</pre>
|
||||
</div>
|
||||
</SettingsOptionRow>
|
||||
) : (
|
||||
foldsQuery.data.map((fold) => (
|
||||
<FoldRow fold={fold} key={fold.fold} />
|
||||
))
|
||||
)}
|
||||
</SettingsOptionGroup>
|
||||
<SettingsOptionGroup title="Automation">
|
||||
<SettingsOptionRow>
|
||||
<p
|
||||
className="text-sm font-normal text-muted-foreground/70"
|
||||
data-settings-subcopy
|
||||
>
|
||||
Everything here is also available to agents and scripts as the
|
||||
`buzz folds` CLI (set, list, get, delete, estimate, run, artifact,
|
||||
share). Scheduled runs are a workflow concern — folds deliberately
|
||||
carry no cadence of their own.
|
||||
</p>
|
||||
</SettingsOptionRow>
|
||||
</SettingsOptionGroup>
|
||||
</SettingsOptionGroupList>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Download,
|
||||
FlaskConical,
|
||||
Keyboard,
|
||||
Layers,
|
||||
LayoutTemplate,
|
||||
MessagesSquare,
|
||||
MonitorCog,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
ThreadLayoutSetting,
|
||||
} from "./AppearanceSettingsControls";
|
||||
import { ChannelTemplatesSettingsCard } from "./ChannelTemplatesSettingsCard";
|
||||
import { AccumulatorSettingsCard } from "@/features/accumulator/ui/AccumulatorSettingsCard";
|
||||
import { ExperimentalFeaturesCard } from "./ExperimentalFeaturesCard";
|
||||
import { KeyboardShortcutsCard } from "./KeyboardShortcutsCard";
|
||||
import { MeshComputeSettingsCard } from "@/features/mesh-compute/ui/MeshComputeSettingsCard";
|
||||
@@ -83,6 +85,7 @@ import { VoiceSettingsCard } from "./VoiceSettingsCard";
|
||||
|
||||
export type SettingsSection =
|
||||
| "profile"
|
||||
| "accumulator"
|
||||
| "notifications"
|
||||
| "voice"
|
||||
| "experimental"
|
||||
@@ -103,6 +106,7 @@ export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
|
||||
|
||||
const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
|
||||
"profile",
|
||||
"accumulator",
|
||||
"notifications",
|
||||
"voice",
|
||||
"experimental",
|
||||
@@ -188,6 +192,12 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
icon: LayoutTemplate,
|
||||
featureGate: "channel-templates",
|
||||
},
|
||||
{
|
||||
value: "accumulator",
|
||||
label: "Accumulator",
|
||||
icon: Layers,
|
||||
featureGate: "accumulator",
|
||||
},
|
||||
{
|
||||
value: "compute",
|
||||
label: "Compute",
|
||||
@@ -836,6 +846,8 @@ export function renderSettingsSection(
|
||||
return <AgentsSettingsPanel />;
|
||||
case "channel-templates":
|
||||
return <ChannelTemplatesSettingsCard />;
|
||||
case "accumulator":
|
||||
return <AccumulatorSettingsCard />;
|
||||
case "compute":
|
||||
return <MeshComputeSettingsCard />;
|
||||
case "appearance":
|
||||
|
||||
@@ -63,6 +63,7 @@ const settingsNavGroups: Array<{
|
||||
"custom-emoji",
|
||||
"local-archive",
|
||||
"channel-templates",
|
||||
"accumulator",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
31
desktop/src/shared/api/tauriFolds.ts
Normal file
31
desktop/src/shared/api/tauriFolds.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
/** Output of one `buzz folds` CLI invocation, verbatim. */
|
||||
export type FoldsCliOutput = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run `buzz folds <args…>` (bundled CLI, desktop identity, active workspace
|
||||
* relay). Nonzero exits resolve normally with the CLI's stderr message —
|
||||
* rejection is reserved for launch failures, timeouts, and arg validation.
|
||||
*/
|
||||
export async function runFoldsCli(args: string[]): Promise<FoldsCliOutput> {
|
||||
return invokeTauri<FoldsCliOutput>("run_folds_cli", { args });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a folds verb that prints JSON on success and parse it. Throws with the
|
||||
* CLI's stderr (which carries the actionable message) on nonzero exit.
|
||||
*/
|
||||
export async function runFoldsCliJson<T>(args: string[]): Promise<T> {
|
||||
const result = await runFoldsCli(args);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
result.stderr.trim() || `buzz folds exited with code ${result.exitCode}`,
|
||||
);
|
||||
}
|
||||
return JSON.parse(result.stdout) as T;
|
||||
}
|
||||
@@ -82,6 +82,11 @@ export const KIND_GIT_STATUS_DRAFT = 1633;
|
||||
// NIP-DV: relay-signed per-viewer DM visibility snapshot (d=viewer pubkey,
|
||||
// h-tags = currently-hidden DM channel ids).
|
||||
export const KIND_DM_VISIBILITY = 30622;
|
||||
// Accumulator folds. Mirror of buzz-core's KIND_FOLD_SPEC/KIND_FOLD_ARTIFACT.
|
||||
// Both author-only and NIP-44 encrypted to self; the desktop reads them via
|
||||
// the bundled `buzz folds` CLI, never directly.
|
||||
export const KIND_FOLD_SPEC = 30640;
|
||||
export const KIND_FOLD_ARTIFACT = 4640;
|
||||
|
||||
// Human-visible "new content" message kinds. Used as the unread trigger set
|
||||
// (sidebar badges, catch-up queries) and as the Home-feed mention query.
|
||||
|
||||
@@ -13476,6 +13476,16 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as { runtimeId?: string },
|
||||
activeConfig,
|
||||
);
|
||||
case "run_folds_cli": {
|
||||
// Accumulator panel: default to an empty fold list so the gated
|
||||
// section renders its empty state without a real CLI spawn.
|
||||
const args = (payload as { args?: string[] })?.args ?? [];
|
||||
return {
|
||||
stdout: args[0] === "list" ? "[]" : "{}",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
};
|
||||
}
|
||||
case "discover_backend_providers":
|
||||
return activeConfig?.mock?.backendProviders ?? [];
|
||||
case "probe_backend_provider": {
|
||||
|
||||
@@ -36,6 +36,12 @@
|
||||
"name": "Agent-managed profiles",
|
||||
"description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy",
|
||||
"platforms": ["desktop"]
|
||||
},
|
||||
{
|
||||
"id": "accumulator",
|
||||
"name": "Accumulator",
|
||||
"description": "Standing fold digests over saved relay selections, run manually via buzz folds",
|
||||
"platforms": ["desktop"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user