pi-landstrip 0.18.5 → 0.18.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.ts +204 -132
  2. package/package.json +2 -2
package/dist/index.ts CHANGED
@@ -88,7 +88,13 @@ import {
88
88
  keyHint,
89
89
  SessionManager
90
90
  } from "@earendil-works/pi-coding-agent";
91
- import { matchesKey, Text, truncateToWidth as truncateToWidth2, visibleWidth as visibleWidth2 } from "@earendil-works/pi-tui";
91
+ import {
92
+ matchesKey,
93
+ Text,
94
+ truncateToWidth as truncateToWidth2,
95
+ visibleWidth as visibleWidth2,
96
+ wrapTextWithAnsi
97
+ } from "@earendil-works/pi-tui";
92
98
  import { Type } from "typebox";
93
99
 
94
100
  // agents.ts
@@ -1273,6 +1279,13 @@ function prepareProjectAgentEditor(cwd, agent, agentDir = getAgentDir3()) {
1273
1279
 
1274
1280
  // box.ts
1275
1281
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
1282
+ function dialogTabs(theme, tabs, active) {
1283
+ return tabs.map((tab) => tab === active ? theme.fg("accent", theme.bold(`[${tab}]`)) : theme.fg("muted", tab)).join(" ");
1284
+ }
1285
+ function dialogKeys(theme, hints) {
1286
+ const separator = theme.fg("dim", " · ");
1287
+ return hints.map(([key, description]) => `${theme.fg("accent", key)} ${theme.fg("muted", description)}`).join(separator);
1288
+ }
1276
1289
  function boxTop(theme, width, title) {
1277
1290
  if (width < 5)
1278
1291
  return truncateToWidth(theme.fg("accent", title), Math.max(1, width));
@@ -1977,7 +1990,7 @@ class PermissionBroker {
1977
1990
  if (signal?.aborted)
1978
1991
  throw new Error("Permission request cancelled");
1979
1992
  const choice = await ctx.ui.select(`${task}: permission required
1980
- ${permission}: ${resource}`, ["Allow once", "Allow for this session", "Reject"], { signal });
1993
+ ${permission}: ${resource}`, ["Allow once", "Allow for this session", "Keep blocked"], { signal });
1981
1994
  if (choice === "Allow for this session")
1982
1995
  this.grants.add(key);
1983
1996
  if (choice !== "Allow once" && choice !== "Allow for this session") {
@@ -2354,7 +2367,7 @@ ${this.primaryAgent.prompt}` };
2354
2367
  return;
2355
2368
  }
2356
2369
  const projectTrusted = isProjectTrusted(ctx);
2357
- const tasks = [...this.tasks.values()];
2370
+ let tasks = [...this.tasks.values()];
2358
2371
  const requested = args.trim();
2359
2372
  let scope = projectTrusted ? "project" : "global";
2360
2373
  let catalog = this.getAgentCatalog(ctx);
@@ -2393,6 +2406,12 @@ ${this.primaryAgent.prompt}` };
2393
2406
  maxSubagentsSettings = loadMaxSubagentsSettings(ctx.cwd, projectTrusted);
2394
2407
  sandboxSettings = sandboxCallbacks?.load(ctx.cwd, projectTrusted) ?? { global: true };
2395
2408
  };
2409
+ const refreshTasks = () => {
2410
+ const selectedId = tasks[selectedTask]?.id;
2411
+ tasks = [...this.tasks.values()];
2412
+ const nextSelected = selectedId ? tasks.findIndex((task) => task.id === selectedId) : selectedTask;
2413
+ selectedTask = Math.max(0, Math.min(nextSelected < 0 ? 0 : nextSelected, tasks.length - 1));
2414
+ };
2396
2415
  const refreshAgentRuntime = async (name) => {
2397
2416
  reloadAgents();
2398
2417
  if (this.primaryAgent?.name === name) {
@@ -2420,42 +2439,43 @@ ${this.primaryAgent.prompt}` };
2420
2439
  ...lines2.map((line) => boxRow(theme, width, line)),
2421
2440
  boxBottom(theme, width)
2422
2441
  ];
2423
- const tabLabel = (label, active) => active ? theme.fg("accent", theme.bold(`[${label}]`)) : theme.fg("muted", label);
2424
- const tabs = `${tabLabel("Agents", tab === "agents")} ${tabLabel("Tasks", tab === "tasks")} ${tabLabel("Settings", tab === "settings")} ${theme.fg("dim", `· ${scope === "project" ? "Project" : "Global"} scope`)}`;
2442
+ const pad = (value, cellWidth) => {
2443
+ const clipped = truncateToWidth2(value, cellWidth);
2444
+ return `${clipped}${" ".repeat(Math.max(0, cellWidth - visibleWidth2(clipped)))}`;
2445
+ };
2446
+ refreshTasks();
2447
+ const tabs = `${dialogTabs(theme, ["Agents", "Tasks", "Settings"], `${tab[0]?.toUpperCase()}${tab.slice(1)}`)}${tab === "tasks" ? "" : theme.fg("dim", ` · Scope: ${scope === "project" ? "Project" : "Global"}`)}`;
2425
2448
  if (tab === "agents") {
2426
2449
  const start = Math.max(0, Math.min(selectedAgent - 3, agents.length - 7));
2427
2450
  const shown2 = agents.slice(start, start + 7);
2428
2451
  const nameWidth = Math.min(20, Math.max(5, ...agents.map((agent2) => visibleWidth2(`@${agent2.name}`))));
2452
+ const primaryWidth = 7;
2429
2453
  const modeWidth = 8;
2430
2454
  const sourceWidth = 8;
2431
- const disabledWidth = 8;
2432
- const fixedWidth = 4 + nameWidth + 1 + modeWidth + 1 + sourceWidth + 1 + disabledWidth;
2455
+ const stateWidth = 13;
2456
+ const fixedWidth = 2 + primaryWidth + 1 + nameWidth + 1 + modeWidth + 1 + sourceWidth + 1 + stateWidth;
2433
2457
  const modelWidth = Math.max(10, Math.min(30, contentWidth - fixedWidth - 1));
2434
- const pad = (value, cellWidth) => {
2435
- const clipped = truncateToWidth2(value, cellWidth);
2436
- return `${clipped}${" ".repeat(Math.max(0, cellWidth - visibleWidth2(clipped)))}`;
2437
- };
2438
2458
  const lines2 = [
2439
2459
  tabs,
2440
2460
  "",
2441
- theme.fg("dim", ` ${pad("Agent", nameWidth)} ${pad("Mode", modeWidth)} ${pad("Source", sourceWidth)} ${pad("Model", modelWidth)} ${pad("Disabled", disabledWidth)}`)
2461
+ theme.fg("dim", ` ${pad("Primary", primaryWidth)} ${pad("Agent", nameWidth)} ${pad("Mode", modeWidth)} ${pad("Source", sourceWidth)} ${pad("Model", modelWidth)} ${pad("State", stateWidth)}`)
2442
2462
  ];
2443
2463
  for (const [offset, agent2] of shown2.entries()) {
2444
2464
  const index = start + offset;
2445
2465
  const selected = index === selectedAgent;
2446
2466
  const cursor = selected ? theme.fg("accent", "›") : " ";
2447
- const active = agent2.name === this.primaryAgent?.name ? theme.fg("success", "●") : theme.fg("dim", "○");
2467
+ const primary = agent2.name === this.primaryAgent?.name ? "yes" : "";
2448
2468
  const current = scopedAgent(agent2);
2449
2469
  const unavailable = current === undefined;
2450
2470
  const scopedDisabled = current?.disabled ?? false;
2451
2471
  const disabled = agent2.disabled;
2452
2472
  const deleting = confirmingDeleteAgent === agent2.name;
2453
2473
  const inherited = scope === "project" && !disabledOverrides.project.has(agent2.name);
2454
- const toggle = unavailable ? "n/a" : inherited ? "[-]" : scopedDisabled ? "[x]" : "[ ]";
2474
+ const state = unavailable ? "unavailable" : inherited ? `inherited ${scopedDisabled ? "off" : "on"}` : scopedDisabled ? "disabled" : "enabled";
2455
2475
  const color = deleting ? "error" : disabled ? "muted" : "text";
2456
2476
  const plainName = pad(`@${agent2.name}`, nameWidth);
2457
2477
  const name = deleting ? theme.fg("error", plainName) : disabled ? theme.fg("muted", plainName) : colorizeAgentText(agent2.color, plainName, (agentColor, text) => theme.fg(agentColor, text));
2458
- lines2.push(`${cursor} ${active} ${name} ${theme.fg(color, pad(agent2.mode, modeWidth))} ${theme.fg(color, pad(agent2.source, sourceWidth))} ${theme.fg(color, pad(agent2.model ?? "current model", modelWidth))} ${theme.fg(deleting ? "error" : unavailable ? "muted" : scopedDisabled ? "warning" : color, pad(toggle, disabledWidth))}`);
2478
+ lines2.push(`${cursor} ${theme.fg(primary ? "success" : "dim", pad(primary, primaryWidth))} ${name} ${theme.fg(color, pad(agent2.mode, modeWidth))} ${theme.fg(color, pad(agent2.source, sourceWidth))} ${theme.fg(color, pad(agent2.model ?? "current model", modelWidth))} ${theme.fg(deleting ? "error" : unavailable ? "muted" : scopedDisabled ? "warning" : color, pad(state, stateWidth))}`);
2459
2479
  }
2460
2480
  if (agents.length === 0)
2461
2481
  lines2.push(theme.fg("muted", " No agents configured."));
@@ -2483,7 +2503,9 @@ ${this.primaryAgent.prompt}` };
2483
2503
  if (agent.variant && !PI_THINKING_LEVELS.has(agent.variant)) {
2484
2504
  unsupported.push(`variant=${agent.variant}`);
2485
2505
  }
2486
- lines2.push(`${theme.fg("dim", "Unsupported RPC options:")} ${theme.fg("text", unsupported.join(", ") || "none")}`);
2506
+ if (unsupported.length > 0) {
2507
+ lines2.push(`${theme.fg("dim", "Unsupported RPC options:")} ${theme.fg("text", unsupported.join(", "))}`);
2508
+ }
2487
2509
  }
2488
2510
  if (catalog.diagnostics.length > 0) {
2489
2511
  lines2.push(theme.fg("error", "Catalog diagnostics"));
@@ -2491,8 +2513,35 @@ ${this.primaryAgent.prompt}` };
2491
2513
  lines2.push(` ${theme.fg("error", diagnostic)}`);
2492
2514
  }
2493
2515
  }
2494
- const canActivate = agent !== undefined && !agent.disabled && agentSupportsMode(agent, "primary");
2495
- lines2.push("", confirmingDeleteAgent ? theme.fg("error", `Delete @${confirmingDeleteAgent}? enter confirm esc cancel`) : theme.fg("dim", saving ? "Saving…" : `↑↓ select${canActivate ? " enter activate" : ""} d toggle ctrl+d delete ctrl+g edit tab next ⇧tab scope esc`));
2516
+ lines2.push("");
2517
+ if (confirmingDeleteAgent) {
2518
+ lines2.push(theme.fg("error", `Delete @${confirmingDeleteAgent}? This removes its project file.`), dialogKeys(theme, [
2519
+ ["Y", "delete"],
2520
+ ["N / Esc", "cancel"]
2521
+ ]));
2522
+ } else if (saving) {
2523
+ lines2.push(theme.fg("dim", "Saving…"));
2524
+ } else {
2525
+ const mainHints = [["↑↓", "select"]];
2526
+ if (agent && !agent.disabled && agentSupportsMode(agent, "primary")) {
2527
+ mainHints.push(["Enter", "set primary"]);
2528
+ }
2529
+ if (agent && scopedAgent(agent))
2530
+ mainHints.push(["Space", "enable / disable"]);
2531
+ const actionHints = [];
2532
+ if (agent && projectTrusted)
2533
+ actionHints.push(["E", "edit"]);
2534
+ if (agent && projectTrusted && canDeleteProjectAgent(ctx.cwd, agent)) {
2535
+ actionHints.push(["X", "delete"]);
2536
+ }
2537
+ if (agent && scope === "project" && disabledOverrides.project.has(agent.name)) {
2538
+ actionHints.push(["I", "inherit"]);
2539
+ }
2540
+ if (projectTrusted)
2541
+ actionHints.push(["S", "scope"]);
2542
+ actionHints.push(["Tab", "next tab"], ["Esc", "close"]);
2543
+ lines2.push(dialogKeys(theme, mainHints), dialogKeys(theme, actionHints));
2544
+ }
2496
2545
  return box(lines2);
2497
2546
  }
2498
2547
  if (tab === "settings") {
@@ -2513,12 +2562,31 @@ ${this.primaryAgent.prompt}` };
2513
2562
  const text = `[ ${row.value} ]`;
2514
2563
  const value = selected ? theme.fg("accent", text) : theme.fg(row.unavailable ? "muted" : "text", text);
2515
2564
  const label = theme.fg(row.unavailable ? "muted" : "text", row.label);
2516
- lines2.push(`${cursor} ${value} ${label}`);
2565
+ const dirty = dirtySettings.has(index) ? theme.fg("warning", " *") : "";
2566
+ lines2.push(`${cursor} ${value} ${label}${dirty}`);
2517
2567
  }
2518
- if (selectedMaxSubagents() === 0) {
2519
- lines2.push(` ${theme.fg("dim", "Subagents disabled")}`);
2568
+ lines2.push("", theme.fg("dim", selectedSetting === 0 ? "Sets concurrent subagents. Zero disables the task tool." : "Controls OS sandboxing. Use /sandbox to inspect its policy."));
2569
+ if (saving) {
2570
+ lines2.push("", theme.fg("dim", "Saving…"));
2571
+ } else {
2572
+ const editHints = [["↑↓", "select"]];
2573
+ if (selectedSetting === 0)
2574
+ editHints.push(["0–9 / +−", "change limit"]);
2575
+ else if (sandboxCallbacks)
2576
+ editHints.push(["Space", "toggle"]);
2577
+ const canInherit = scope === "project" && (selectedSetting === 0 ? maxSubagentsSettings.project !== undefined : sandboxSettings.project !== undefined);
2578
+ if (canInherit)
2579
+ editHints.push(["I", "inherit"]);
2580
+ const actionHints = [];
2581
+ if (dirtySettings.size > 0) {
2582
+ if (dirtySettings.has(selectedSetting))
2583
+ actionHints.push(["Enter", "save"]);
2584
+ actionHints.push(["R", "discard"]);
2585
+ } else {
2586
+ actionHints.push(["S", "scope"], ["Tab", "next tab"], ["Esc", "close"]);
2587
+ }
2588
+ lines2.push("", dialogKeys(theme, editHints), dialogKeys(theme, actionHints));
2520
2589
  }
2521
- lines2.push("", theme.fg("dim", saving ? "Saving…" : "↑↓ select 0-9/+/- max space toggle i inherit enter save tab next ⇧tab scope esc close"));
2522
2590
  return box(lines2);
2523
2591
  }
2524
2592
  if (!detail) {
@@ -2535,23 +2603,24 @@ ${this.primaryAgent.prompt}` };
2535
2603
  lines2.push(`${cursor} ${indent}${taskState(theme, task2)} ${theme.fg("accent", `@${task2.agent}`)} ${theme.fg("text", task2.description)} ${theme.fg("dim", task2.id.slice(0, 8))}`);
2536
2604
  }
2537
2605
  }
2538
- lines2.push("", theme.fg("dim", "↑↓ select enter inspect tab next ⇧tab scope esc close"));
2606
+ lines2.push("", dialogKeys(theme, [
2607
+ ["↑↓", "select"],
2608
+ ["Enter", "inspect"],
2609
+ ["Tab", "next tab"],
2610
+ ["Esc", "close"]
2611
+ ]));
2539
2612
  return box(lines2);
2540
2613
  }
2541
2614
  const task = tasks[selectedTask];
2542
2615
  if (!task)
2543
2616
  return box([tabs, "", "No task sessions in this session."]);
2544
- const siblings = tasks.filter((candidate) => candidate.parentTaskId === task.parentTaskId);
2545
- const siblingIndex = siblings.findIndex((candidate) => candidate.id === task.id);
2546
- const transcript = taskTranscript(task).flatMap((line) => line.split(`
2547
- `));
2617
+ const transcript = taskTranscript(task).flatMap((line) => wrapTextWithAnsi(line, contentWidth));
2548
2618
  const maxScroll = Math.max(0, transcript.length - INSPECTOR_BODY_LINES);
2549
2619
  scroll = Math.min(scroll, maxScroll);
2550
2620
  const shown = transcript.slice(scroll, scroll + INSPECTOR_BODY_LINES);
2551
2621
  const duration = taskDuration(taskDetails(task));
2552
2622
  const metrics = [
2553
2623
  task.id,
2554
- `${siblingIndex + 1} of ${siblings.length}`,
2555
2624
  task.toolCalls === undefined ? undefined : `${task.toolCalls} tool call${task.toolCalls === 1 ? "" : "s"}`,
2556
2625
  duration
2557
2626
  ].filter(Boolean);
@@ -2566,19 +2635,24 @@ ${this.primaryAgent.prompt}` };
2566
2635
  while (lines.length < INSPECTOR_BODY_LINES + 5)
2567
2636
  lines.push("");
2568
2637
  if (transcript.length > INSPECTOR_BODY_LINES) {
2569
- lines.push(theme.fg("dim", `${scroll + 1}-${scroll + shown.length} of ${transcript.length}`));
2638
+ lines.push(theme.fg("dim", `${scroll + 1}–${scroll + shown.length} of ${transcript.length}`));
2570
2639
  }
2571
- lines.push(theme.fg("dim", "Parent p/backspace Prev ← Next → ↑↓ scroll tab next ⇧tab scope esc back"));
2640
+ lines.push("", dialogKeys(theme, [
2641
+ ["↑↓", "scroll"],
2642
+ ...task.parentTaskId ? [["Backspace", "parent"]] : [],
2643
+ ["Esc", "task list"],
2644
+ ["Tab", "next tab"]
2645
+ ]));
2572
2646
  return box(lines.map((line) => truncateToWidth2(line, contentWidth)));
2573
2647
  },
2574
2648
  handleInput: (data) => {
2575
2649
  if (confirmingDeleteAgent !== undefined) {
2576
- if (matchesKey(data, "escape")) {
2650
+ if (matchesKey(data, "escape") || data.toLowerCase() === "n") {
2577
2651
  confirmingDeleteAgent = undefined;
2578
2652
  tui.requestRender();
2579
2653
  return;
2580
2654
  }
2581
- if (matchesKey(data, "return")) {
2655
+ if (data.toLowerCase() === "y") {
2582
2656
  const name = confirmingDeleteAgent;
2583
2657
  const agent = agents.find((candidate) => candidate.name === name);
2584
2658
  confirmingDeleteAgent = undefined;
@@ -2600,15 +2674,18 @@ ${this.primaryAgent.prompt}` };
2600
2674
  }
2601
2675
  return;
2602
2676
  }
2603
- if (matchesKey(data, "shift+tab")) {
2677
+ if (data.toLowerCase() === "s" && tab !== "tasks") {
2604
2678
  if (saving)
2605
2679
  return;
2680
+ if (dirtySettings.size > 0) {
2681
+ ctx.ui.notify("Save or discard settings changes before changing scope", "warning");
2682
+ return;
2683
+ }
2606
2684
  if (!projectTrusted && scope === "global") {
2607
2685
  ctx.ui.notify("Project settings require a trusted project", "warning");
2608
2686
  return;
2609
2687
  }
2610
2688
  scope = scope === "project" ? "global" : "project";
2611
- dirtySettings.clear();
2612
2689
  editing = false;
2613
2690
  reloadAgents();
2614
2691
  reloadSettings();
@@ -2616,6 +2693,10 @@ ${this.primaryAgent.prompt}` };
2616
2693
  return;
2617
2694
  }
2618
2695
  if (matchesKey(data, "tab")) {
2696
+ if (tab === "settings" && dirtySettings.size > 0) {
2697
+ ctx.ui.notify("Save changes with Enter or discard them with R before leaving", "warning");
2698
+ return;
2699
+ }
2619
2700
  const tabs = ["agents", "tasks", "settings"];
2620
2701
  tab = tabs[(tabs.indexOf(tab) + 1) % tabs.length] ?? "agents";
2621
2702
  detail = false;
@@ -2634,15 +2715,19 @@ ${this.primaryAgent.prompt}` };
2634
2715
  selectedAgent = Math.max(0, selectedAgent - 1);
2635
2716
  else if (matchesKey(data, "down") && agents.length > 0) {
2636
2717
  selectedAgent = Math.min(agents.length - 1, selectedAgent + 1);
2637
- } else if (matchesKey(data, "ctrl+d") && agent && !saving) {
2638
- if (!projectTrusted || !canDeleteProjectAgent(ctx.cwd, agent))
2718
+ } else if (data.toLowerCase() === "x" && agent && !saving) {
2719
+ if (!projectTrusted || !canDeleteProjectAgent(ctx.cwd, agent)) {
2720
+ ctx.ui.notify("Only project agent files can be deleted here", "warning");
2639
2721
  return;
2722
+ }
2640
2723
  confirmingDeleteAgent = agent.name;
2641
2724
  tui.requestRender();
2642
2725
  return;
2643
- } else if (matchesKey(data, "ctrl+g") && agent && !saving) {
2644
- if (!projectTrusted)
2726
+ } else if (data.toLowerCase() === "e" && agent && !saving) {
2727
+ if (!projectTrusted) {
2728
+ ctx.ui.notify("Agent editing requires a trusted project", "warning");
2645
2729
  return;
2730
+ }
2646
2731
  saving = true;
2647
2732
  (async () => {
2648
2733
  const document = prepareProjectAgentEditor(ctx.cwd, agent);
@@ -2657,7 +2742,7 @@ ${this.primaryAgent.prompt}` };
2657
2742
  tui.requestRender();
2658
2743
  });
2659
2744
  return;
2660
- } else if ((data === "d" || data === " ") && agent && !saving) {
2745
+ } else if (data === " " && agent && !saving) {
2661
2746
  const current = scopedAgent(agent);
2662
2747
  if (!current) {
2663
2748
  ctx.ui.notify("Local-only agents are unavailable in Global scope", "warning");
@@ -2673,7 +2758,7 @@ ${this.primaryAgent.prompt}` };
2673
2758
  tui.requestRender();
2674
2759
  });
2675
2760
  return;
2676
- } else if (data === "i" && scope === "project" && agent && !saving) {
2761
+ } else if (data.toLowerCase() === "i" && scope === "project" && agent && !saving) {
2677
2762
  if (!disabledOverrides.project.has(agent.name))
2678
2763
  return;
2679
2764
  saving = true;
@@ -2688,6 +2773,9 @@ ${this.primaryAgent.prompt}` };
2688
2773
  } else if (matchesKey(data, "return") && agent && !agent.disabled && agentSupportsMode(agent, "primary")) {
2689
2774
  this.selectPrimaryAgent(agent.name, ctx).then(() => tui.requestRender()).catch((error) => ctx.ui.notify(`Could not select primary agent: ${formatError(error)}`, "error"));
2690
2775
  return;
2776
+ } else if (matchesKey(data, "return") && agent) {
2777
+ ctx.ui.notify(agent.disabled ? "Enable this agent before setting it as primary" : "This agent cannot be used as the primary agent", "warning");
2778
+ return;
2691
2779
  } else
2692
2780
  return;
2693
2781
  tui.requestRender();
@@ -2697,9 +2785,20 @@ ${this.primaryAgent.prompt}` };
2697
2785
  if (saving)
2698
2786
  return;
2699
2787
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
2788
+ if (dirtySettings.size > 0) {
2789
+ ctx.ui.notify("Save changes with Enter or discard them with R before closing", "warning");
2790
+ return;
2791
+ }
2700
2792
  done();
2701
2793
  return;
2702
2794
  }
2795
+ if (data.toLowerCase() === "r") {
2796
+ reloadSettings();
2797
+ dirtySettings.clear();
2798
+ editing = false;
2799
+ tui.requestRender();
2800
+ return;
2801
+ }
2703
2802
  if (matchesKey(data, "up")) {
2704
2803
  selectedSetting = Math.max(0, selectedSetting - 1);
2705
2804
  editing = false;
@@ -2769,10 +2868,6 @@ ${this.primaryAgent.prompt}` };
2769
2868
  if (!detail) {
2770
2869
  if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c"))
2771
2870
  done();
2772
- else if (matchesKey(data, "left"))
2773
- tab = "agents";
2774
- else if (matchesKey(data, "right"))
2775
- tab = "settings";
2776
2871
  else if (matchesKey(data, "up"))
2777
2872
  selectedTask = Math.max(0, selectedTask - 1);
2778
2873
  else if (matchesKey(data, "down") && tasks.length > 0) {
@@ -2797,21 +2892,13 @@ ${this.primaryAgent.prompt}` };
2797
2892
  scroll = Math.max(0, scroll - 1);
2798
2893
  } else if (matchesKey(data, "down")) {
2799
2894
  scroll += 1;
2800
- } else if (data === "p" || matchesKey(data, "backspace")) {
2895
+ } else if (matchesKey(data, "backspace")) {
2801
2896
  const parentIndex = task.parentTaskId ? tasks.findIndex((candidate) => candidate.id === task.parentTaskId) : -1;
2802
2897
  if (parentIndex >= 0)
2803
2898
  selectedTask = parentIndex;
2804
2899
  else
2805
2900
  detail = false;
2806
2901
  scroll = 0;
2807
- } else if (matchesKey(data, "left") || matchesKey(data, "right")) {
2808
- const siblings = tasks.filter((candidate) => candidate.parentTaskId === task.parentTaskId);
2809
- const siblingIndex = siblings.findIndex((candidate) => candidate.id === task.id);
2810
- const offset = matchesKey(data, "left") ? -1 : 1;
2811
- const sibling = siblings[siblingIndex + offset];
2812
- if (sibling)
2813
- selectedTask = tasks.indexOf(sibling);
2814
- scroll = 0;
2815
2902
  } else
2816
2903
  return;
2817
2904
  tui.requestRender();
@@ -2883,7 +2970,7 @@ ${descriptions}` : ""),
2883
2970
  const background = input.background ? theme.fg("muted", " (background)") : "";
2884
2971
  const description = input.description?.trim() || "...";
2885
2972
  const agent = input.subagent_type?.trim() || "...";
2886
- const title = theme.fg("toolTitle", theme.bold("Agent Task"));
2973
+ const title = theme.fg("toolTitle", theme.bold("Agent"));
2887
2974
  return new Text(`${title}${background}${theme.fg("muted", " — ")}${theme.fg("text", description)}
2888
2975
  ` + theme.fg("dim", ` @${agent}`), 0, 0);
2889
2976
  },
@@ -3821,28 +3908,26 @@ var PROXY_ENVIRONMENT_VARIABLES = [
3821
3908
  ];
3822
3909
  var packageDir2 = dirname5(fileURLToPath2(import.meta.url));
3823
3910
  var PERMISSION_OPTIONS = [
3824
- { label: "Allow once", key: "o", action: "once" },
3825
- { label: "Allow for this session only", key: "s", action: "session" },
3826
- { label: "Abort (keep blocked)", key: "esc", action: "abort" },
3911
+ { label: "Allow once", action: "once" },
3912
+ { label: "Allow for this session", action: "session" },
3827
3913
  {
3828
3914
  label: "Allow for this project",
3829
- key: "P",
3830
3915
  action: "project",
3831
3916
  confirm: true,
3832
- hint: "-> .pi/sandbox.json"
3917
+ hint: "save to .pi/sandbox.json"
3833
3918
  },
3834
3919
  {
3835
3920
  label: "Allow for all projects",
3836
- key: "A",
3837
3921
  action: "global",
3838
3922
  confirm: true,
3839
- hint: "-> ~/.pi/agent/sandbox.json"
3840
- }
3923
+ hint: "save to ~/.pi/agent/sandbox.json"
3924
+ },
3925
+ { label: "Keep blocked", action: "abort" }
3841
3926
  ];
3842
3927
  var NETWORK_PERMISSION_OPTIONS = [
3843
- { label: "Allow once", key: "o", action: "once" },
3844
- { label: "Allow for this session only", key: "s", action: "session" },
3845
- { label: "Abort (keep blocked)", key: "esc", action: "abort" }
3928
+ { label: "Allow once", action: "once" },
3929
+ { label: "Allow for this session", action: "session" },
3930
+ { label: "Keep blocked", action: "abort" }
3846
3931
  ];
3847
3932
  function loadSandboxConfig(cwd, includeProject) {
3848
3933
  const projectConfigPath = join7(cwd, ".pi", "sandbox.json");
@@ -3956,10 +4041,7 @@ function writeConfigFile(configPath, config) {
3956
4041
  }
3957
4042
  function getSandboxConfigWriteTarget(cwd, includeProject = true) {
3958
4043
  const { globalPath, projectPath } = getConfigPaths(cwd);
3959
- if (includeProject && existsSync5(projectPath)) {
3960
- return { scope: "project", path: projectPath };
3961
- }
3962
- return { scope: "global", path: globalPath };
4044
+ return includeProject ? { scope: "project", path: projectPath } : { scope: "global", path: globalPath };
3963
4045
  }
3964
4046
  async function writeSandboxConfigEnabled(cwd, enabled, scope) {
3965
4047
  const paths = getConfigPaths(cwd);
@@ -4316,21 +4398,23 @@ async function stopPreparedChild(child) {
4316
4398
  async function showPermissionPrompt(ctx, title, options, signal) {
4317
4399
  if (!ctx.hasUI)
4318
4400
  return "abort";
4319
- const labels = options.map((option2) => option2.hint ? `${option2.label} ${option2.hint}` : option2.label);
4401
+ const labels = options.map((option2) => option2.hint ? `${option2.label} — ${option2.hint}` : option2.label);
4320
4402
  const selected = await ctx.ui.select(title, labels, { signal });
4321
4403
  const index = selected === undefined ? -1 : labels.indexOf(selected);
4322
4404
  const option = options[index];
4323
4405
  if (!option)
4324
4406
  return "abort";
4325
4407
  if (option.confirm) {
4326
- const confirmed = await ctx.ui.confirm(`Confirm ${option.label.toLowerCase()}`, option.hint ?? "This changes persisted sandbox policy.", { signal });
4408
+ const confirmed = await ctx.ui.confirm("Save this permission?", `${title}
4409
+
4410
+ ${option.hint ?? "This changes persisted sandbox policy."}`, { signal });
4327
4411
  if (!confirmed)
4328
4412
  return "abort";
4329
4413
  }
4330
4414
  return option.action;
4331
4415
  }
4332
4416
  function promptDomainBlock(ctx, domain, signal) {
4333
- return showPermissionPrompt(ctx, `Network blocked: "${domain}" is not in allowedDomains`, PERMISSION_OPTIONS, signal);
4417
+ return showPermissionPrompt(ctx, `Network blocked: "${domain}" is not in the allowed domains list`, PERMISSION_OPTIONS, signal);
4334
4418
  }
4335
4419
  function promptReadBlock(ctx, filePath, reason, signal) {
4336
4420
  const title = reason ? `Read blocked: "${filePath}" is in denyRead (${reason})` : `Read blocked: "${filePath}" is not in allowRead`;
@@ -4340,7 +4424,7 @@ function promptWriteBlock(ctx, filePath, signal) {
4340
4424
  return showPermissionPrompt(ctx, `Write blocked: "${filePath}" is not in allowWrite`, PERMISSION_OPTIONS, signal);
4341
4425
  }
4342
4426
  function promptNetworkBlock(ctx, operation, target, signal) {
4343
- return showPermissionPrompt(ctx, `Network blocked: ${operation} to "${target}"`, NETWORK_PERMISSION_OPTIONS, signal);
4427
+ return showPermissionPrompt(ctx, `Network blocked: ${operation} "${target}"`, NETWORK_PERMISSION_OPTIONS, signal);
4344
4428
  }
4345
4429
  function landstripAvailable() {
4346
4430
  try {
@@ -5622,82 +5706,62 @@ ${message}
5622
5706
  handler: async (_args, ctx) => {
5623
5707
  if (!ctx.hasUI)
5624
5708
  return;
5625
- const config = loadConfig(ctx.cwd);
5709
+ const trustContext = ctx;
5710
+ const projectTrusted = trustContext.isProjectTrusted?.() ?? projectConfigTrusted;
5711
+ const config = loadSandboxConfig(ctx.cwd, projectTrusted);
5626
5712
  const { globalPath, projectPath } = getConfigPaths(ctx.cwd);
5627
5713
  const windowsImplementation = process.platform === "win32" ? activeWindowsImplementation() : undefined;
5628
- const shouldToggle = await ctx.ui.custom((_tui, theme, _kb, done) => {
5629
- const { dim, muted, accent, text } = themeColors(theme);
5630
- function sandboxStatus() {
5714
+ const target = getSandboxConfigWriteTarget(ctx.cwd, projectTrusted);
5715
+ let tab = "Overview";
5716
+ const shouldToggle = await ctx.ui.custom((tui, theme, _kb, done) => {
5717
+ const { muted, accent, text } = themeColors(theme);
5718
+ const sandboxStatus = () => {
5631
5719
  if (noSandboxFlag)
5632
- return { color: "warning", label: "Disabled (--no-sandbox)" };
5720
+ return { color: "warning", label: "Disabled by --no-sandbox" };
5633
5721
  if (!config.enabled)
5634
- return { color: "warning", label: "Disabled" };
5722
+ return { color: "warning", label: "Disabled by configuration" };
5635
5723
  if (!sandboxEnabled || !sandboxReady)
5636
5724
  return { color: "warning", label: "Inactive" };
5637
5725
  return { color: "success", label: "Active" };
5638
- }
5639
- function boolValue(value) {
5640
- return value ? theme.fg("warning", "yes") : theme.fg("success", "no");
5641
- }
5726
+ };
5727
+ const networkMode = config.network.allowNetwork ? "Unrestricted" : windowsImplementation === "appContainer" && !config.windows.allowLoopback ? "Blocked" : "Proxied";
5642
5728
  return {
5643
5729
  render(width) {
5644
5730
  const innerWidth = Math.max(1, width - 4);
5645
- const row = (content = "") => boxRow(theme, width, content);
5646
- const lines = [boxTop(theme, width, "Sandbox")];
5647
5731
  const status = sandboxStatus();
5648
- const statusDot = theme.fg(status.color, "●");
5649
- const pathSnippet = text(truncateToWidth3(landstripDisplayPath(), Math.max(20, innerWidth - 28)));
5650
- const section = (title, detail) => {
5651
- lines.push(row(""));
5652
- lines.push(row(`${accent(title)}${detail ? dim(` · ${detail}`) : ""}`));
5653
- };
5654
- const item = (label, value) => {
5655
- lines.push(row(` ${dim("•")} ${muted(label.padEnd(13))} ${value}`));
5656
- };
5732
+ const tabs = dialogTabs(theme, ["Overview", "Policy"], tab);
5657
5733
  const listValue = (values) => {
5658
5734
  const value = values.join(", ") || "none";
5659
- return text(truncateToWidth3(value, Math.max(10, innerWidth - 17)));
5735
+ return text(truncateToWidth3(value, Math.max(10, innerWidth - 20)));
5660
5736
  };
5661
- lines.push(row(`${statusDot} ${text(status.label)} ${dim("·")} ${muted("landstrip")} ${pathSnippet}`));
5662
- section("Config");
5663
- item("project", text(projectPath));
5664
- item("global", text(globalPath));
5665
- const networkMode = config.network.allowNetwork ? "unrestricted" : windowsImplementation === "appContainer" && !config.windows.allowLoopback ? "blocked" : "proxied";
5666
- section("Network", networkMode);
5667
- item("allow network", boolValue(config.network.allowNetwork));
5668
- item("allowed", listValue(config.network.allowedDomains));
5669
- item("denied", listValue(config.network.deniedDomains));
5670
- item("unix sockets", config.network.allowAllUnixSockets ? text("all") : listValue(config.network.allowUnixSockets));
5671
- if (sessionAllowedDomains.length > 0) {
5672
- item("session", theme.fg("accent", sessionAllowedDomains.join(", ")));
5673
- }
5674
- section("Filesystem");
5675
- item("deny read", listValue(config.filesystem.denyRead));
5676
- item("allow read", listValue(config.filesystem.allowRead));
5677
- item("allow write", listValue(config.filesystem.allowWrite));
5678
- item("deny write", listValue(config.filesystem.denyWrite));
5679
- if (windowsImplementation !== undefined) {
5680
- section("Windows");
5681
- item("implementation", text(windowsImplementation === "restrictedUser" ? "restricted user" : "AppContainer"));
5682
- if (windowsImplementation === "appContainer") {
5683
- const mode = config.windows.appContainerMode;
5684
- item("container", mode === "standard" ? theme.fg("warning", `${mode} (weaker)`) : text(mode));
5685
- item("loopback", boolValue(config.windows.allowLoopback));
5686
- }
5687
- }
5688
- if (sessionAllowedReadPaths.length > 0 || sessionAllowedWritePaths.length > 0) {
5689
- section("Session grants");
5690
- if (sessionAllowedReadPaths.length > 0) {
5691
- item("read", theme.fg("accent", sessionAllowedReadPaths.join(", ")));
5692
- }
5693
- if (sessionAllowedWritePaths.length > 0) {
5694
- item("write", theme.fg("accent", sessionAllowedWritePaths.join(", ")));
5737
+ const item = (label, value) => ` ${muted(label.padEnd(16))} ${value}`;
5738
+ const footer = dialogKeys(theme, [
5739
+ ["Tab", "next tab"],
5740
+ ["Enter", `${config.enabled ? "disable" : "enable"} in ${target.scope}`],
5741
+ ["Esc", "close"]
5742
+ ]);
5743
+ const content = [tabs, ""];
5744
+ if (tab === "Overview") {
5745
+ content.push(`${theme.fg(status.color, "●")} ${text(status.label)}`, item("Configured", config.enabled ? text("Enabled") : theme.fg("warning", "Disabled")), item("Runtime", sandboxEnabled && sandboxReady ? theme.fg("success", "Running") : muted("Not running")), "", accent("Protection"), item("Network", text(networkMode)), item("Filesystem", text(`${config.filesystem.denyRead.length + config.filesystem.allowRead.length} read rules, ${config.filesystem.allowWrite.length + config.filesystem.denyWrite.length} write rules`)), item("Session grants", text(String(sessionAllowedDomains.length + sessionAllowedReadPaths.length + sessionAllowedWritePaths.length))), "", accent("Configuration"), item("Change scope", text(target.scope === "project" ? "Project" : "Global")), item("Change file", text(truncateToWidth3(target.path, Math.max(10, innerWidth - 20)))), item("Project file", text(truncateToWidth3(projectPath, Math.max(10, innerWidth - 20)))), item("Global file", text(truncateToWidth3(globalPath, Math.max(10, innerWidth - 20)))), item("Landstrip binary", text(truncateToWidth3(landstripDisplayPath(), Math.max(10, innerWidth - 20)))));
5746
+ if (windowsImplementation !== undefined) {
5747
+ content.push(item("Windows mode", text(windowsImplementation === "restrictedUser" ? "Restricted user" : `AppContainer (${config.windows.appContainerMode})`)));
5695
5748
  }
5749
+ } else {
5750
+ content.push(accent(`Network · ${networkMode}`), item("Allowed domains", listValue(config.network.allowedDomains)), item("Denied domains", listValue(config.network.deniedDomains)), item("Unix sockets", config.network.allowAllUnixSockets ? text("all") : listValue(config.network.allowUnixSockets)), "", accent("Filesystem"), item("Denied reads", listValue(config.filesystem.denyRead)), item("Allowed reads", listValue(config.filesystem.allowRead)), item("Allowed writes", listValue(config.filesystem.allowWrite)), item("Denied writes", listValue(config.filesystem.denyWrite)), "", accent("Session grants"), item("Domains", listValue(sessionAllowedDomains)), item("Read paths", listValue(sessionAllowedReadPaths)), item("Write paths", listValue(sessionAllowedWritePaths)));
5696
5751
  }
5697
- lines.push(row(""), row(`${dim("enter")} ${muted(config.enabled ? "disable" : "enable")} ${dim("esc")} ${muted("close")}`), boxBottom(theme, width));
5698
- return lines;
5752
+ content.push("", footer);
5753
+ return [
5754
+ boxTop(theme, width, "Sandbox"),
5755
+ ...content.map((line) => boxRow(theme, width, line)),
5756
+ boxBottom(theme, width)
5757
+ ];
5699
5758
  },
5700
5759
  handleInput(data) {
5760
+ if (matchesKey2(data, "tab")) {
5761
+ tab = tab === "Overview" ? "Policy" : "Overview";
5762
+ tui.requestRender();
5763
+ return;
5764
+ }
5701
5765
  if (matchesKey2(data, "return")) {
5702
5766
  done(true);
5703
5767
  return;
@@ -5714,8 +5778,16 @@ ${message}
5714
5778
  if (!shouldToggle)
5715
5779
  return;
5716
5780
  const enabled = !config.enabled;
5781
+ if (!enabled) {
5782
+ const confirmed = await ctx.ui.confirm("Disable sandbox?", `Commands will run without OS isolation.
5783
+
5784
+ Change: ${target.scope} configuration
5785
+ ${target.path}`);
5786
+ if (!confirmed)
5787
+ return;
5788
+ }
5717
5789
  try {
5718
- const scope = await setSandboxConfigEnabled(ctx.cwd, enabled, projectConfigTrusted);
5790
+ const scope = await setSandboxConfigEnabled(ctx.cwd, enabled, projectTrusted);
5719
5791
  if (!enabled) {
5720
5792
  disableSandbox(ctx);
5721
5793
  } else if (!noSandboxFlag) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-landstrip",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Sandboxed Bash and process-backed agents for Pi",
5
5
  "keywords": [
6
6
  "landstrip",
@@ -39,7 +39,7 @@
39
39
  "ci:test": "npm run test"
40
40
  },
41
41
  "dependencies": {
42
- "@landstrip/landstrip": "^0.18.5",
42
+ "@landstrip/landstrip": "^0.18.7",
43
43
  "jsonc-parser": "^3.3.1",
44
44
  "minimatch": "^10.2.5",
45
45
  "yaml": "^2.9.0"