opencode-landstrip 0.18.34 → 0.18.36

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 (6) hide show
  1. package/README.md +23 -70
  2. package/index.ts +56 -467
  3. package/package.json +2 -2
  4. package/shared.ts +100 -37
  5. package/tui.ts +290 -154
  6. package/LICENSE +0 -201
package/tui.ts CHANGED
@@ -12,7 +12,7 @@ import type {
12
12
  TuiSlotPlugin,
13
13
  } from '@opencode-ai/plugin/tui';
14
14
  import { RGBA } from '@opentui/core';
15
- import { useTerminalDimensions } from '@opentui/solid';
15
+ import { Portal, useTerminalDimensions } from '@opentui/solid';
16
16
  import { Fragment, jsx, jsxs } from '@opentui/solid/jsx-runtime';
17
17
  import { createSignal, onCleanup, onMount } from 'solid-js';
18
18
 
@@ -27,7 +27,7 @@ import {
27
27
  removeDiscoveryFile,
28
28
  sessionAllows,
29
29
  sessionAllowancesFor,
30
- sandboxSummary,
30
+ sandboxConfigTarget,
31
31
  sessionScopeFor,
32
32
  rootSessionIDFor,
33
33
  setSandboxConfigEnabled,
@@ -118,26 +118,21 @@ function formatPath(input: string, base: string): string {
118
118
  return absolute;
119
119
  }
120
120
 
121
- function selectedForeground(theme: TuiSlotContext['theme']['current']): RGBA {
122
- const resolved = theme as typeof theme & { _hasSelectedListItemText?: boolean };
123
- if (resolved._hasSelectedListItemText) return theme.selectedListItemText;
124
-
125
- if (theme.background.a === 0) {
126
- const { r, g, b } = theme.warning;
127
- const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
128
- return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
129
- }
130
- return theme.background;
121
+ function selectedForeground(background: RGBA): RGBA {
122
+ const luminance = 0.2126 * background.r + 0.7152 * background.g + 0.0722 * background.b;
123
+ return luminance > 0.5 ? RGBA.fromInts(0, 0, 0) : RGBA.fromInts(255, 255, 255);
131
124
  }
132
125
 
133
126
  const tui: TuiPlugin = async (api, options, meta) => {
134
127
  const optionOverrides = normalizeOptions(options);
128
+ const enabledManagedByOptions = optionOverrides.enabled !== undefined;
135
129
 
136
130
  function LandstripPermissionPrompt(rawProps: Record<string, unknown>) {
137
131
  const props = rawProps as unknown as PermissionPromptProps<string>;
138
132
  const theme = api.theme.current;
139
133
  const dimensions = useTerminalDimensions();
140
134
  const [selected, setSelected] = createSignal(0);
135
+ const [expanded, setExpanded] = createSignal(false);
141
136
  onMount(props.onShow);
142
137
 
143
138
  function move(direction: number): void {
@@ -152,12 +147,16 @@ const tui: TuiPlugin = async (api, options, meta) => {
152
147
  const unregister = api.keymap.registerLayer({
153
148
  priority: 1000,
154
149
  bindings: [
155
- { key: 'left', cmd: () => move(-1) },
156
- { key: 'h', cmd: () => move(-1) },
157
- { key: 'right', cmd: () => move(1) },
158
- { key: 'l', cmd: () => move(1) },
159
- { key: 'return', cmd: submit },
160
- { key: 'escape', cmd: props.onCancel },
150
+ { key: 'left', desc: 'Previous permission option', group: 'Sandbox', cmd: () => move(-1) },
151
+ { key: 'right', desc: 'Next permission option', group: 'Sandbox', cmd: () => move(1) },
152
+ { key: 'return', desc: 'Select permission option', group: 'Sandbox', cmd: submit },
153
+ { key: 'escape', desc: 'Reject permission', group: 'Sandbox', cmd: props.onCancel },
154
+ {
155
+ key: 'ctrl+f',
156
+ desc: 'Toggle permission prompt fullscreen',
157
+ group: 'Sandbox',
158
+ cmd: () => setExpanded((value) => !value),
159
+ },
161
160
  ],
162
161
  });
163
162
  onCleanup(unregister);
@@ -177,13 +176,126 @@ const tui: TuiPlugin = async (api, options, meta) => {
177
176
  },
178
177
  children: jsx('text', {
179
178
  get fg() {
180
- return selected() === index ? selectedForeground(theme) : theme.textMuted;
179
+ return selected() === index ? selectedForeground(theme.warning) : theme.textMuted;
181
180
  },
182
181
  children: option.label,
183
182
  }),
184
183
  }),
185
184
  );
186
185
 
186
+ const children = [
187
+ jsxs('box', {
188
+ gap: 1,
189
+ paddingLeft: 1,
190
+ paddingRight: 3,
191
+ paddingTop: 1,
192
+ paddingBottom: 1,
193
+ flexGrow: 1,
194
+ children: [
195
+ jsxs('box', {
196
+ flexDirection: 'column',
197
+ gap: 0,
198
+ paddingLeft: 1,
199
+ flexShrink: 0,
200
+ children: [
201
+ jsxs('box', {
202
+ flexDirection: 'row',
203
+ gap: 1,
204
+ flexShrink: 0,
205
+ children: [
206
+ jsx('text', { fg: theme.warning, children: '△' }),
207
+ jsx('text', { fg: theme.text, children: 'Permission required' }),
208
+ ],
209
+ }),
210
+ jsxs('box', {
211
+ flexDirection: 'row',
212
+ gap: 1,
213
+ paddingLeft: 2,
214
+ flexShrink: 0,
215
+ children: [
216
+ jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
217
+ jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
218
+ ],
219
+ }),
220
+ ],
221
+ }),
222
+ ],
223
+ }),
224
+ jsxs('box', {
225
+ get flexDirection() {
226
+ return dimensions().width < 80 ? 'column' : 'row';
227
+ },
228
+ flexShrink: 0,
229
+ gap: 1,
230
+ paddingTop: 1,
231
+ paddingLeft: 2,
232
+ paddingRight: 3,
233
+ paddingBottom: 1,
234
+ backgroundColor: theme.backgroundElement,
235
+ get justifyContent() {
236
+ return dimensions().width < 80 ? 'flex-start' : 'space-between';
237
+ },
238
+ get alignItems() {
239
+ return dimensions().width < 80 ? 'flex-start' : 'center';
240
+ },
241
+ children: [
242
+ jsx('box', {
243
+ flexDirection: 'row',
244
+ gap: 1,
245
+ flexShrink: 0,
246
+ children: optionButtons,
247
+ }),
248
+ jsxs('box', {
249
+ flexDirection: 'row',
250
+ gap: 2,
251
+ flexShrink: 0,
252
+ children: [
253
+ jsxs('text', {
254
+ fg: theme.text,
255
+ children: [
256
+ 'ctrl+f ',
257
+ jsx('span', {
258
+ style: { fg: theme.textMuted },
259
+ children: expanded() ? 'minimize' : 'fullscreen',
260
+ }),
261
+ ],
262
+ }),
263
+ jsxs('text', {
264
+ fg: theme.text,
265
+ children: [
266
+ '⇆ ',
267
+ jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
268
+ ],
269
+ }),
270
+ jsxs('text', {
271
+ fg: theme.text,
272
+ children: [
273
+ 'enter ',
274
+ jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
275
+ ],
276
+ }),
277
+ ],
278
+ }),
279
+ ],
280
+ }),
281
+ ];
282
+
283
+ if (expanded()) {
284
+ return Portal({
285
+ children: jsxs('box', {
286
+ top: dimensions().height * -1 + 1,
287
+ bottom: 1,
288
+ left: 2,
289
+ right: 2,
290
+ position: 'absolute',
291
+ border: ['left'],
292
+ borderColor: theme.warning,
293
+ customBorderChars: promptBorderChars,
294
+ backgroundColor: theme.backgroundPanel,
295
+ children,
296
+ }),
297
+ });
298
+ }
187
299
  return jsxs('box', {
188
300
  top: 0,
189
301
  maxHeight: 15,
@@ -195,92 +307,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
195
307
  borderColor: theme.warning,
196
308
  customBorderChars: promptBorderChars,
197
309
  backgroundColor: theme.backgroundPanel,
198
- children: [
199
- jsxs('box', {
200
- gap: 1,
201
- paddingLeft: 1,
202
- paddingRight: 3,
203
- paddingTop: 1,
204
- paddingBottom: 1,
205
- flexGrow: 1,
206
- children: [
207
- jsxs('box', {
208
- flexDirection: 'column',
209
- gap: 0,
210
- paddingLeft: 1,
211
- flexShrink: 0,
212
- children: [
213
- jsxs('box', {
214
- flexDirection: 'row',
215
- gap: 1,
216
- flexShrink: 0,
217
- children: [
218
- jsx('text', { fg: theme.warning, children: '△' }),
219
- jsx('text', { fg: theme.text, children: 'Permission required' }),
220
- ],
221
- }),
222
- jsxs('box', {
223
- flexDirection: 'row',
224
- gap: 1,
225
- paddingLeft: 2,
226
- flexShrink: 0,
227
- children: [
228
- jsx('text', { fg: theme.textMuted, flexShrink: 0, children: props.icon }),
229
- jsx('text', { fg: theme.text, wrapMode: 'word', children: props.title }),
230
- ],
231
- }),
232
- ],
233
- }),
234
- ],
235
- }),
236
- jsxs('box', {
237
- get flexDirection() {
238
- return dimensions().width < 80 ? 'column' : 'row';
239
- },
240
- flexShrink: 0,
241
- gap: 1,
242
- paddingTop: 1,
243
- paddingLeft: 2,
244
- paddingRight: 3,
245
- paddingBottom: 1,
246
- backgroundColor: theme.backgroundElement,
247
- get justifyContent() {
248
- return dimensions().width < 80 ? 'flex-start' : 'space-between';
249
- },
250
- get alignItems() {
251
- return dimensions().width < 80 ? 'flex-start' : 'center';
252
- },
253
- children: [
254
- jsx('box', {
255
- flexDirection: 'row',
256
- gap: 1,
257
- flexShrink: 0,
258
- children: optionButtons,
259
- }),
260
- jsxs('box', {
261
- flexDirection: 'row',
262
- gap: 2,
263
- flexShrink: 0,
264
- children: [
265
- jsxs('text', {
266
- fg: theme.text,
267
- children: [
268
- '⇆ ',
269
- jsx('span', { style: { fg: theme.textMuted }, children: 'select' }),
270
- ],
271
- }),
272
- jsxs('text', {
273
- fg: theme.text,
274
- children: [
275
- 'enter ',
276
- jsx('span', { style: { fg: theme.textMuted }, children: 'confirm' }),
277
- ],
278
- }),
279
- ],
280
- }),
281
- ],
282
- }),
283
- ],
310
+ children,
284
311
  });
285
312
  }
286
313
 
@@ -662,68 +689,176 @@ const tui: TuiPlugin = async (api, options, meta) => {
662
689
  refreshPermissionPresentation,
663
690
  );
664
691
 
665
- // /sandbox shows the config and toggles the persisted `enabled` flag. The
666
- // server reads sandbox.json on every tool call, so the toggle takes effect on
667
- // the next command without any cross-process signalling.
668
- const showSandbox = () => {
692
+ const [landstripOpen, setLandstripOpen] = createSignal(false);
693
+ const [confirmingDisable, setConfirmingDisable] = createSignal(false);
694
+ const [sandboxRevision, setSandboxRevision] = createSignal(0);
695
+ let unregisterLandstripKeys: (() => void) | undefined;
696
+ let popLandstripMode: (() => void) | undefined;
697
+
698
+ const closeLandstrip = () => {
699
+ unregisterLandstripKeys?.();
700
+ popLandstripMode?.();
701
+ unregisterLandstripKeys = undefined;
702
+ popLandstripMode = undefined;
703
+ setConfirmingDisable(false);
704
+ setLandstripOpen(false);
705
+ };
706
+ const toggleLandstrip = () => {
707
+ if (enabledManagedByOptions) {
708
+ api.ui.toast({
709
+ title: 'Landstrip',
710
+ message: 'Sandbox state is managed by plugin options',
711
+ variant: 'warning',
712
+ });
713
+ return;
714
+ }
715
+
669
716
  const directory = api.state.path.directory || process.cwd();
670
717
  const config = loadConfig(directory, optionOverrides);
671
- const { globalPath, projectPath } = getConfigPaths(directory);
672
- const next = !config.enabled;
673
- const message =
674
- sandboxSummary(config, globalPath, projectPath) +
675
- `\n\n${next ? 'Enable' : 'Disable'} the sandbox? (enter = ${next ? 'enable' : 'disable'}, esc = close)`;
676
-
677
- // No `clear()` in onConfirm: the host pops the dialog itself, and its
678
- // `clear()` re-invokes onClose, which would recurse and freeze the TUI.
679
- api.ui.dialog.replace(() =>
680
- api.ui.DialogConfirm({
681
- title: 'Sandbox',
682
- message,
683
- onConfirm: () => {
684
- const scope = setSandboxConfigEnabled(directory, next);
685
- refreshSandboxStatus?.();
686
- api.ui.toast({
687
- title: 'Sandbox',
688
- message: `Sandbox ${next ? 'enabled' : 'disabled'} (${scope} config)`,
689
- variant: next ? 'success' : 'warning',
690
- });
691
- },
692
- }),
693
- );
718
+ if (config.enabled && !confirmingDisable()) {
719
+ setConfirmingDisable(true);
720
+ return;
721
+ }
722
+ setConfirmingDisable(false);
723
+ const enabled = !config.enabled;
724
+ const scope = setSandboxConfigEnabled(directory, enabled, optionOverrides);
725
+ refreshSandboxStatus?.();
726
+ api.ui.toast({
727
+ title: 'Landstrip',
728
+ message: `Sandbox ${enabled ? 'enabled' : 'disabled'} (${scope} config)`,
729
+ variant: enabled ? 'success' : 'warning',
730
+ });
731
+ };
732
+ const cancelLandstrip = () => {
733
+ if (confirmingDisable()) setConfirmingDisable(false);
734
+ else closeLandstrip();
694
735
  };
695
736
 
737
+ function LandstripPane() {
738
+ const theme = api.theme.current;
739
+ const dimensions = useTerminalDimensions();
740
+
741
+ const row = (label: string, value: string) =>
742
+ jsxs('box', {
743
+ flexDirection: 'row',
744
+ paddingLeft: 2,
745
+ paddingRight: 2,
746
+ children: [
747
+ jsx('text', { fg: theme.textMuted, width: 22, children: label }),
748
+ jsx('text', { fg: theme.text, flexGrow: 1, wrapMode: 'word', children: value }),
749
+ ],
750
+ });
751
+
752
+ return Portal({
753
+ get children() {
754
+ if (!landstripOpen()) return null;
755
+ sandboxRevision();
756
+ const directory = api.state.path.directory || process.cwd();
757
+ const config = loadConfig(directory, optionOverrides);
758
+ const target = sandboxConfigTarget(directory);
759
+ const values = (items: readonly string[]) => items.join(', ') || 'none';
760
+ const status = enabledManagedByOptions
761
+ ? config.enabled
762
+ ? 'Active (plugin options)'
763
+ : 'Disabled by plugin options'
764
+ : config.enabled
765
+ ? 'Active'
766
+ : 'Disabled by configuration';
767
+ return jsxs('box', {
768
+ position: 'absolute',
769
+ zIndex: 4000,
770
+ bottom: 0,
771
+ left: 0,
772
+ right: 0,
773
+ get height() {
774
+ return Math.min(22, Math.max(12, dimensions().height - 2));
775
+ },
776
+ flexDirection: 'column',
777
+ border: ['top'],
778
+ borderColor: theme.primary,
779
+ backgroundColor: theme.backgroundPanel,
780
+ children: [
781
+ jsxs('box', {
782
+ flexDirection: 'row',
783
+ paddingLeft: 2,
784
+ paddingRight: 2,
785
+ paddingTop: 1,
786
+ paddingBottom: 1,
787
+ justifyContent: 'space-between',
788
+ children: [
789
+ jsx('text', { fg: theme.primary, children: 'Landstrip' }),
790
+ jsx('text', {
791
+ fg: config.enabled ? theme.success : theme.warning,
792
+ children: status,
793
+ }),
794
+ ],
795
+ }),
796
+ row('Network', config.network.allowNetwork ? 'Unrestricted' : 'Proxied'),
797
+ row('Allowed Domains', values(config.network.allowedDomains)),
798
+ row('Denied Domains', values(config.network.deniedDomains)),
799
+ row('Allowed Reads', values(config.filesystem.allowRead)),
800
+ row('Denied Reads', values(config.filesystem.denyRead)),
801
+ row('Allowed Writes', values(config.filesystem.allowWrite)),
802
+ row('Denied Writes', values(config.filesystem.denyWrite)),
803
+ row(
804
+ 'Configuration Scope',
805
+ enabledManagedByOptions
806
+ ? 'Plugin options'
807
+ : target.scope === 'project'
808
+ ? 'Project'
809
+ : 'Global',
810
+ ),
811
+ row('Configuration File', formatPath(target.path, directory)),
812
+ jsx('box', { flexGrow: 1 }),
813
+ jsx('text', {
814
+ fg: confirmingDisable() ? theme.error : theme.textMuted,
815
+ marginLeft: 2,
816
+ marginRight: 2,
817
+ marginBottom: 1,
818
+ children: confirmingDisable()
819
+ ? 'Disable the sandbox? Commands will run without OS isolation. Enter confirm · Esc cancel'
820
+ : enabledManagedByOptions
821
+ ? 'Sandbox state is managed by plugin options · Esc close'
822
+ : `Enter ${config.enabled ? 'disable' : 'enable'} · Esc close`,
823
+ }),
824
+ ],
825
+ });
826
+ },
827
+ });
828
+ }
829
+
830
+ const showLandstrip = () => {
831
+ if (landstripOpen()) return;
832
+ setLandstripOpen(true);
833
+ popLandstripMode = api.mode.push('modal');
834
+ unregisterLandstripKeys = api.keymap.registerLayer({
835
+ priority: 1000,
836
+ bindings: [
837
+ { key: 'return', desc: 'Toggle sandbox', group: 'Landstrip', cmd: toggleLandstrip },
838
+ { key: 'escape', desc: 'Close Landstrip', group: 'Landstrip', cmd: cancelLandstrip },
839
+ ],
840
+ });
841
+ };
842
+ api.lifecycle.onDispose(closeLandstrip);
843
+
696
844
  api.keymap.registerLayer({
697
845
  commands: [
698
846
  {
699
847
  namespace: 'palette',
700
- name: 'sandbox',
701
- title: 'Sandbox',
702
- desc: 'Inspect and toggle the sandbox',
703
- category: 'Sandbox',
848
+ name: 'landstrip',
849
+ title: 'Landstrip',
850
+ desc: 'Manage the Landstrip sandbox',
851
+ category: 'Landstrip',
704
852
  suggested: true,
705
- slash: { name: 'sandbox' },
706
- slashName: 'sandbox',
707
- run: showSandbox,
853
+ slash: { name: 'landstrip' },
854
+ slashName: 'landstrip',
855
+ run: showLandstrip,
708
856
  },
709
857
  ],
710
858
  });
711
859
 
712
- api.command?.register(() => [
713
- {
714
- title: 'Sandbox',
715
- value: 'sandbox',
716
- description: 'Inspect and toggle the sandbox',
717
- category: 'Sandbox',
718
- suggested: true,
719
- slash: { name: 'sandbox' },
720
- onSelect: showSandbox,
721
- },
722
- ]);
723
-
724
860
  // Persistent status badge in the prompt area.
725
861
  try {
726
- const [sandboxRevision, setSandboxRevision] = createSignal(0);
727
862
  const statusBadge = (ctx: TuiSlotContext) => {
728
863
  sandboxRevision();
729
864
  const directory = api.state.path.directory || process.cwd();
@@ -741,6 +876,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
741
876
 
742
877
  const statusSlot: TuiSlotPlugin = {
743
878
  slots: {
879
+ app: () => jsx(LandstripPane, {}),
744
880
  home_prompt_right: (ctx) => statusBadge(ctx),
745
881
  session_prompt: (_ctx, props) => renderSessionPrompt(props),
746
882
  session_prompt_right: (ctx) => statusBadge(ctx),
@@ -749,7 +885,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
749
885
  api.slots.register(statusSlot);
750
886
  refreshSandboxStatus = () => setSandboxRevision((revision) => revision + 1);
751
887
  } catch {
752
- // Solid runtime unavailable on this host — skip the status badge.
888
+ // Solid runtime unavailable on this host — skip the pane and status badge.
753
889
  }
754
890
 
755
891
  // First-run onboarding: a single quiet pointer to the default-strict policy
@@ -759,7 +895,7 @@ const tui: TuiPlugin = async (api, options, meta) => {
759
895
  api.kv.set('onboarded', true);
760
896
  api.ui.toast({
761
897
  title: 'Sandbox active',
762
- message: 'Landstrip sandbox is on. Run /sandbox to inspect it.',
898
+ message: 'Landstrip sandbox is on. Run /landstrip to inspect it.',
763
899
  variant: 'info',
764
900
  duration: 8000,
765
901
  });