@opencode-cockpit/shell 0.1.5 → 0.2.1
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.
- package/README.md +105 -10
- package/dist/agent/plugin.js +180 -0
- package/dist/agent/tools/index.js +23 -0
- package/dist/agent/tools/list.js +68 -0
- package/dist/agent/tools/read.js +59 -0
- package/dist/agent/tools/restart.js +44 -0
- package/dist/agent/tools/send.js +71 -0
- package/dist/agent/tools/shared.js +94 -0
- package/dist/agent/tools/start.js +152 -0
- package/dist/agent/tools/stop.js +42 -0
- package/dist/agent/tools/wait.js +79 -0
- package/dist/agent/tools/watch-args.js +60 -0
- package/dist/agent/tools/watch.js +72 -0
- package/dist/core/config.js +97 -0
- package/dist/{tools → core}/find.js +3 -0
- package/dist/{tools → core}/format.js +27 -2
- package/dist/core/kind.js +31 -0
- package/dist/server.js +2 -152
- package/dist/tui/{badge.js → components/badge.js} +1 -1
- package/dist/tui/{console.js → components/console.js} +270 -139
- package/dist/tui/{dock.js → components/dock.js} +75 -19
- package/dist/tui/{sidebar.js → components/sidebar.js} +16 -1
- package/dist/tui/dialogs.js +163 -0
- package/dist/tui/index.js +35 -94
- package/dist/tui/lib/details.js +23 -0
- package/dist/tui/lib/search.js +39 -0
- package/dist/tui/lib/update.js +58 -0
- package/dist/tui/{view.js → lib/view.js} +55 -2
- package/dist/tui/{store.js → state/store.js} +4 -2
- package/package.json +4 -5
- package/types/agent/plugin.d.ts +12 -0
- package/types/agent/tools/index.d.ts +5 -0
- package/types/agent/tools/list.d.ts +3 -0
- package/types/agent/tools/read.d.ts +3 -0
- package/types/agent/tools/restart.d.ts +3 -0
- package/types/agent/tools/send.d.ts +3 -0
- package/types/agent/tools/shared.d.ts +40 -0
- package/types/agent/tools/start.d.ts +3 -0
- package/types/agent/tools/stop.d.ts +3 -0
- package/types/agent/tools/wait.d.ts +3 -0
- package/types/agent/tools/watch-args.d.ts +18 -0
- package/types/agent/tools/watch.d.ts +3 -0
- package/types/core/config.d.ts +60 -0
- package/types/{tools → core}/find.d.ts +5 -0
- package/types/core/kind.d.ts +9 -0
- package/types/server.d.ts +2 -12
- package/types/tui/{console.d.ts → components/console.d.ts} +7 -1
- package/types/tui/{dock.d.ts → components/dock.d.ts} +3 -1
- package/types/tui/{sidebar.d.ts → components/sidebar.d.ts} +1 -1
- package/types/tui/dialogs.d.ts +10 -0
- package/types/tui/index.d.ts +3 -9
- package/types/tui/lib/details.d.ts +3 -0
- package/types/tui/lib/search.d.ts +7 -0
- package/types/tui/lib/update.d.ts +25 -0
- package/types/tui/{view.d.ts → lib/view.d.ts} +16 -1
- package/types/tui/{store.d.ts → state/store.d.ts} +9 -0
- package/dist/tools/index.js +0 -433
- package/types/tools/index.d.ts +0 -17
- /package/dist/{tools → agent/tools}/keys.js +0 -0
- /package/dist/tui/{keys.js → lib/keys.js} +0 -0
- /package/types/{tools → agent/tools}/keys.d.ts +0 -0
- /package/types/{tools → core}/format.d.ts +0 -0
- /package/types/tui/{badge.d.ts → components/badge.d.ts} +0 -0
- /package/types/tui/{keys.d.ts → lib/keys.d.ts} +0 -0
|
@@ -10,9 +10,9 @@ import { createElement as _$createElement } from "@opentui/solid";
|
|
|
10
10
|
|
|
11
11
|
import { useTerminalDimensions } from "@opentui/solid";
|
|
12
12
|
import { createMemo, For, Show } from "solid-js";
|
|
13
|
+
import { displayCommand, kindColor, kindOf, statusDetail, tailLines, tailRuns, truncate, watchColor, watchLabel } from "../lib/view.js";
|
|
14
|
+
import { useScreen } from "../state/store.js";
|
|
13
15
|
import { Badge } from "./badge.js";
|
|
14
|
-
import { useScreen } from "./store.js";
|
|
15
|
-
import { displayCommand, kindColor, kindOf, statusDetail, tailLines, truncate } from "./view.js";
|
|
16
16
|
/** Split pane under the chat: status tabs for shells and the selected shell's live screen. */
|
|
17
17
|
export function Dock(props) {
|
|
18
18
|
const theme = () => props.api.theme.current;
|
|
@@ -25,7 +25,9 @@ export function Dock(props) {
|
|
|
25
25
|
const tabLimit = () => Math.max(1, Math.floor((dims().width - 26) / 30));
|
|
26
26
|
const tabs = createMemo(() => (props.store.showAll() ? props.store.shells() : props.store.visible()).slice(0, tabLimit()));
|
|
27
27
|
const overflow = createMemo(() => props.store.shells().length - tabs().length);
|
|
28
|
-
const
|
|
28
|
+
const bodyCols = () => Math.max(10, dims().width - 4);
|
|
29
|
+
const body = createMemo(() => tailLines(screen()?.text, bodyRows(), bodyCols()));
|
|
30
|
+
const bodyRuns = createMemo(() => props.colors === false ? [] : tailRuns(screen()?.styled, bodyRows(), bodyCols()));
|
|
29
31
|
return (() => {
|
|
30
32
|
var _el$ = _$createElement("box"),
|
|
31
33
|
_el$2 = _$createElement("box"),
|
|
@@ -139,18 +141,59 @@ export function Dock(props) {
|
|
|
139
141
|
return props.store.selected();
|
|
140
142
|
},
|
|
141
143
|
children: shell => [(() => {
|
|
142
|
-
var _el$13 = _$createElement("box")
|
|
143
|
-
_el$14 = _$createElement("text");
|
|
144
|
-
_$insertNode(_el$13, _el$14);
|
|
144
|
+
var _el$13 = _$createElement("box");
|
|
145
145
|
_$setProp(_el$13, "flexGrow", 1);
|
|
146
146
|
_$setProp(_el$13, "paddingLeft", 2);
|
|
147
147
|
_$setProp(_el$13, "paddingRight", 1);
|
|
148
148
|
_$setProp(_el$13, "minHeight", 0);
|
|
149
149
|
_$setProp(_el$13, "overflow", "hidden");
|
|
150
150
|
_$setProp(_el$13, "onMouseUp", () => props.onOpenConsole(shell().id));
|
|
151
|
-
_$
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
_$insert(_el$13, _$createComponent(Show, {
|
|
152
|
+
get when() {
|
|
153
|
+
return bodyRuns().length > 0;
|
|
154
|
+
},
|
|
155
|
+
get fallback() {
|
|
156
|
+
return (() => {
|
|
157
|
+
var _el$19 = _$createElement("text");
|
|
158
|
+
_$setProp(_el$19, "wrapMode", "none");
|
|
159
|
+
_$insert(_el$19, () => body() || " ");
|
|
160
|
+
_$effect(_$p => _$setProp(_el$19, "fg", theme().text, _$p));
|
|
161
|
+
return _el$19;
|
|
162
|
+
})();
|
|
163
|
+
},
|
|
164
|
+
get children() {
|
|
165
|
+
var _el$14 = _$createElement("box");
|
|
166
|
+
_$setProp(_el$14, "flexDirection", "column");
|
|
167
|
+
_$insert(_el$14, _$createComponent(For, {
|
|
168
|
+
get each() {
|
|
169
|
+
return bodyRuns();
|
|
170
|
+
},
|
|
171
|
+
children: row => (() => {
|
|
172
|
+
var _el$20 = _$createElement("text"),
|
|
173
|
+
_el$21 = _$createTextNode(` `);
|
|
174
|
+
_$insertNode(_el$20, _el$21);
|
|
175
|
+
_$setProp(_el$20, "wrapMode", "none");
|
|
176
|
+
_$insert(_el$20, _$createComponent(For, {
|
|
177
|
+
each: row,
|
|
178
|
+
children: run => (() => {
|
|
179
|
+
var _el$22 = _$createElement("span");
|
|
180
|
+
_$insert(_el$22, () => run.text);
|
|
181
|
+
_$effect(_$p => _$setProp(_el$22, "style", {
|
|
182
|
+
fg: run.fg,
|
|
183
|
+
bg: run.bg,
|
|
184
|
+
bold: run.bold,
|
|
185
|
+
italic: run.italic
|
|
186
|
+
}, _$p));
|
|
187
|
+
return _el$22;
|
|
188
|
+
})()
|
|
189
|
+
}), _el$21);
|
|
190
|
+
_$effect(_$p => _$setProp(_el$20, "fg", theme().text, _$p));
|
|
191
|
+
return _el$20;
|
|
192
|
+
})()
|
|
193
|
+
}));
|
|
194
|
+
return _el$14;
|
|
195
|
+
}
|
|
196
|
+
}));
|
|
154
197
|
return _el$13;
|
|
155
198
|
})(), (() => {
|
|
156
199
|
var _el$15 = _$createElement("box"),
|
|
@@ -165,25 +208,38 @@ export function Dock(props) {
|
|
|
165
208
|
_$setProp(_el$16, "wrapMode", "none");
|
|
166
209
|
_$setProp(_el$16, "flexShrink", 0);
|
|
167
210
|
_$insert(_el$16, () => statusDetail(shell(), props.store.now()));
|
|
211
|
+
_$insert(_el$15, _$createComponent(Show, {
|
|
212
|
+
get when() {
|
|
213
|
+
return watchLabel(shell());
|
|
214
|
+
},
|
|
215
|
+
get children() {
|
|
216
|
+
var _el$17 = _$createElement("text");
|
|
217
|
+
_$setProp(_el$17, "wrapMode", "none");
|
|
218
|
+
_$setProp(_el$17, "flexShrink", 0);
|
|
219
|
+
_$insert(_el$17, () => watchLabel(shell()));
|
|
220
|
+
_$effect(_$p => _$setProp(_el$17, "fg", watchColor(theme(), shell()), _$p));
|
|
221
|
+
return _el$17;
|
|
222
|
+
}
|
|
223
|
+
}), null);
|
|
168
224
|
_$insert(_el$15, _$createComponent(Show, {
|
|
169
225
|
get when() {
|
|
170
226
|
return _$memo(() => kindOf(shell()) === "fail")() && shell().summary;
|
|
171
227
|
},
|
|
172
228
|
get fallback() {
|
|
173
229
|
return (() => {
|
|
174
|
-
var _el$
|
|
175
|
-
_$setProp(_el$
|
|
176
|
-
_$insert(_el$
|
|
177
|
-
_$effect(_$p => _$setProp(_el$
|
|
178
|
-
return _el$
|
|
230
|
+
var _el$23 = _$createElement("text");
|
|
231
|
+
_$setProp(_el$23, "wrapMode", "none");
|
|
232
|
+
_$insert(_el$23, () => truncate(`$ ${displayCommand(shell())}`, Math.max(20, dims().width - 40)));
|
|
233
|
+
_$effect(_$p => _$setProp(_el$23, "fg", theme().textMuted, _$p));
|
|
234
|
+
return _el$23;
|
|
179
235
|
})();
|
|
180
236
|
},
|
|
181
237
|
get children() {
|
|
182
|
-
var _el$
|
|
183
|
-
_$setProp(_el$
|
|
184
|
-
_$insert(_el$
|
|
185
|
-
_$effect(_$p => _$setProp(_el$
|
|
186
|
-
return _el$
|
|
238
|
+
var _el$18 = _$createElement("text");
|
|
239
|
+
_$setProp(_el$18, "wrapMode", "none");
|
|
240
|
+
_$insert(_el$18, () => truncate(`${shell().summary}`, Math.max(20, dims().width - 40)));
|
|
241
|
+
_$effect(_$p => _$setProp(_el$18, "fg", theme().error, _$p));
|
|
242
|
+
return _el$18;
|
|
187
243
|
}
|
|
188
244
|
}), null);
|
|
189
245
|
_$effect(_$p => _$setProp(_el$16, "fg", kindColor(theme(), kindOf(shell())), _$p));
|
|
@@ -9,8 +9,8 @@ import { createElement as _$createElement } from "@opentui/solid";
|
|
|
9
9
|
/** @jsxImportSource @opentui/solid */
|
|
10
10
|
|
|
11
11
|
import { createMemo, For, Show } from "solid-js";
|
|
12
|
+
import { kindOf, shortDetail, truncate, watchColor, watchLabel } from "../lib/view.js";
|
|
12
13
|
import { Badge } from "./badge.js";
|
|
13
|
-
import { kindOf, shortDetail, truncate } from "./view.js";
|
|
14
14
|
export function SidebarShells(props) {
|
|
15
15
|
const theme = () => props.api.theme.current;
|
|
16
16
|
const limit = () => Math.max(1, props.store.showAll() ? props.expandedRows ?? 12 : props.rows ?? 5);
|
|
@@ -72,6 +72,21 @@ export function SidebarShells(props) {
|
|
|
72
72
|
_$setProp(_el$9, "wrapMode", "none");
|
|
73
73
|
_$insert(_el$9, () => truncate(shell.title, 20), _el$1);
|
|
74
74
|
_$insert(_el$10, () => shortDetail(shell, props.store.now()));
|
|
75
|
+
_$insert(_el$9, _$createComponent(Show, {
|
|
76
|
+
get when() {
|
|
77
|
+
return watchLabel(shell);
|
|
78
|
+
},
|
|
79
|
+
get children() {
|
|
80
|
+
var _el$11 = _$createElement("span"),
|
|
81
|
+
_el$12 = _$createTextNode(` `);
|
|
82
|
+
_$insertNode(_el$11, _el$12);
|
|
83
|
+
_$insert(_el$11, () => watchLabel(shell), null);
|
|
84
|
+
_$effect(_$p => _$setProp(_el$11, "style", {
|
|
85
|
+
fg: watchColor(theme(), shell)
|
|
86
|
+
}, _$p));
|
|
87
|
+
return _el$11;
|
|
88
|
+
}
|
|
89
|
+
}), null);
|
|
75
90
|
_$effect(_p$ => {
|
|
76
91
|
var _v$3 = theme().text,
|
|
77
92
|
_v$4 = {
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
|
+
/** @jsxImportSource @opentui/solid */
|
|
3
|
+
|
|
4
|
+
import { rmSync } from "node:fs";
|
|
5
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
6
|
+
import { cacheDirFor, fetchLatestVersion, isNewer, shouldCheck } from "./lib/update.js";
|
|
7
|
+
import { BADGE_LABEL, displayCommand, kindOf, order } from "./lib/view.js";
|
|
8
|
+
const PACKAGE_NAME = pkg.name;
|
|
9
|
+
|
|
10
|
+
/** One quiet registry check a day; a newer release is announced once per version. */
|
|
11
|
+
export async function announceUpdate(api, meta, current) {
|
|
12
|
+
if (meta.source !== "npm") return;
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
if (!shouldCheck(api.kv.get("cockpit.update.checkedAt", undefined), now)) return;
|
|
15
|
+
api.kv.set("cockpit.update.checkedAt", now);
|
|
16
|
+
const latest = await fetchLatestVersion(PACKAGE_NAME);
|
|
17
|
+
if (!latest || !isNewer(latest, current)) return;
|
|
18
|
+
if (api.kv.get("cockpit.update.announced", "") === latest) return;
|
|
19
|
+
api.kv.set("cockpit.update.announced", latest);
|
|
20
|
+
api.ui.toast({
|
|
21
|
+
variant: "info",
|
|
22
|
+
title: "opencode-cockpit",
|
|
23
|
+
message: `${latest} is available (you have ${current}). Run /cockpit-update.`,
|
|
24
|
+
duration: 10_000
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Clears this plugin's cache entry so the next OpenCode start installs the newer release. */
|
|
29
|
+
export function offerUpdate(api, meta, current) {
|
|
30
|
+
const dir = cacheDirFor(meta.target, meta.source);
|
|
31
|
+
if (!dir) {
|
|
32
|
+
api.ui.toast({
|
|
33
|
+
variant: "info",
|
|
34
|
+
title: "opencode-cockpit",
|
|
35
|
+
message: meta.source === "file" ? `Running from ${meta.target} — update it with git, not npm.` : "This install is not managed by OpenCode's plugin installer."
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
void fetchLatestVersion(PACKAGE_NAME).then(latest => {
|
|
40
|
+
if (latest && !isNewer(latest, current)) {
|
|
41
|
+
api.ui.toast({
|
|
42
|
+
variant: "success",
|
|
43
|
+
title: "opencode-cockpit",
|
|
44
|
+
message: `${current} is the latest release.`
|
|
45
|
+
});
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const DialogConfirm = api.ui.DialogConfirm;
|
|
49
|
+
api.ui.dialog.replace(() => _$createComponent(DialogConfirm, {
|
|
50
|
+
title: latest ? `Update to ${latest}?` : "Reinstall this plugin?",
|
|
51
|
+
message: `Removes the cached copy in ${dir}. OpenCode installs the new version the next time it starts, so restart it afterwards.`,
|
|
52
|
+
onConfirm: () => {
|
|
53
|
+
api.ui.dialog.clear();
|
|
54
|
+
try {
|
|
55
|
+
rmSync(dir, {
|
|
56
|
+
recursive: true,
|
|
57
|
+
force: true
|
|
58
|
+
});
|
|
59
|
+
api.ui.toast({
|
|
60
|
+
variant: "success",
|
|
61
|
+
title: "opencode-cockpit",
|
|
62
|
+
message: `Cached ${current} removed — restart OpenCode to install ${latest ?? "the latest release"}.`,
|
|
63
|
+
duration: 10_000
|
|
64
|
+
});
|
|
65
|
+
} catch (err) {
|
|
66
|
+
api.ui.toast({
|
|
67
|
+
variant: "error",
|
|
68
|
+
title: "opencode-cockpit",
|
|
69
|
+
message: String(err)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
onCancel: () => api.ui.dialog.clear()
|
|
74
|
+
}));
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export function newShell(api, store, open) {
|
|
78
|
+
const DialogPrompt = api.ui.DialogPrompt;
|
|
79
|
+
api.ui.dialog.replace(() => _$createComponent(DialogPrompt, {
|
|
80
|
+
title: "New background shell",
|
|
81
|
+
placeholder: "npm run dev",
|
|
82
|
+
onConfirm: value => {
|
|
83
|
+
const command = value.trim();
|
|
84
|
+
if (!command) return api.ui.dialog.clear();
|
|
85
|
+
const shell = process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash";
|
|
86
|
+
store.client.call("shell.start", {
|
|
87
|
+
command: shell,
|
|
88
|
+
args: ["-c", command],
|
|
89
|
+
cwd: store.project(),
|
|
90
|
+
title: command.slice(0, 60),
|
|
91
|
+
owner: {
|
|
92
|
+
project: store.project()
|
|
93
|
+
},
|
|
94
|
+
reuse: true
|
|
95
|
+
}).then(info => {
|
|
96
|
+
void store.refresh();
|
|
97
|
+
open(info.id);
|
|
98
|
+
}).catch(err => {
|
|
99
|
+
api.ui.dialog.clear();
|
|
100
|
+
api.ui.toast({
|
|
101
|
+
variant: "error",
|
|
102
|
+
title: "Shell",
|
|
103
|
+
message: err instanceof Error ? err.message : String(err)
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
onCancel: () => api.ui.dialog.clear()
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
export function restartDaemon(api, store) {
|
|
111
|
+
const running = store.shells().filter(s => s.status === "running").length;
|
|
112
|
+
const restart = force => {
|
|
113
|
+
api.ui.dialog.clear();
|
|
114
|
+
store.client.restartDaemon({
|
|
115
|
+
force
|
|
116
|
+
}).then(ok => {
|
|
117
|
+
void store.refresh();
|
|
118
|
+
api.ui.toast({
|
|
119
|
+
variant: ok ? "success" : "warning",
|
|
120
|
+
title: "Shells",
|
|
121
|
+
message: ok ? "Shell daemon restarted" : "Shells are running; restart was not forced"
|
|
122
|
+
});
|
|
123
|
+
}).catch(err => api.ui.toast({
|
|
124
|
+
variant: "error",
|
|
125
|
+
title: "Shells",
|
|
126
|
+
message: String(err)
|
|
127
|
+
}));
|
|
128
|
+
};
|
|
129
|
+
if (running === 0) return restart(false);
|
|
130
|
+
const DialogConfirm = api.ui.DialogConfirm;
|
|
131
|
+
api.ui.dialog.replace(() => _$createComponent(DialogConfirm, {
|
|
132
|
+
title: "Restart shell daemon?",
|
|
133
|
+
message: `${running} running shell${running === 1 ? "" : "s"} will be stopped.`,
|
|
134
|
+
onConfirm: () => restart(true),
|
|
135
|
+
onCancel: () => api.ui.dialog.clear()
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
export function pickShell(api, store, open) {
|
|
139
|
+
const DialogSelect = api.ui.DialogSelect;
|
|
140
|
+
const shells = order(store.shells());
|
|
141
|
+
if (shells.length === 0) {
|
|
142
|
+
api.ui.toast({
|
|
143
|
+
variant: "info",
|
|
144
|
+
title: "Shells",
|
|
145
|
+
message: "No shells in this project yet"
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
api.ui.dialog.replace(() => _$createComponent(DialogSelect, {
|
|
150
|
+
title: "Shells",
|
|
151
|
+
get current() {
|
|
152
|
+
return store.selected()?.id;
|
|
153
|
+
},
|
|
154
|
+
get options() {
|
|
155
|
+
return shells.map(s => ({
|
|
156
|
+
title: s.title,
|
|
157
|
+
value: s.id,
|
|
158
|
+
description: `${BADGE_LABEL[kindOf(s)]} · ${displayCommand(s).slice(0, 60)}`
|
|
159
|
+
}));
|
|
160
|
+
},
|
|
161
|
+
onSelect: option => open(option.value)
|
|
162
|
+
}));
|
|
163
|
+
}
|
package/dist/tui/index.js
CHANGED
|
@@ -1,20 +1,27 @@
|
|
|
1
1
|
import { memo as _$memo } from "@opentui/solid";
|
|
2
2
|
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
3
3
|
/** @jsxImportSource @opentui/solid */
|
|
4
|
+
|
|
4
5
|
import { createBindingLookup } from "@opencode-ai/plugin/tui";
|
|
5
6
|
import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client";
|
|
6
7
|
import { createSignal } from "solid-js";
|
|
8
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
7
9
|
import { createClient } from "../connect.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { loadConfig } from "../core/config.js";
|
|
11
|
+
import { Console } from "./components/console.js";
|
|
12
|
+
import { Dock } from "./components/dock.js";
|
|
13
|
+
import { SidebarShells } from "./components/sidebar.js";
|
|
14
|
+
import { announceUpdate, newShell, offerUpdate, pickShell, restartDaemon } from "./dialogs.js";
|
|
15
|
+
import { createShellStore } from "./state/store.js";
|
|
13
16
|
const DEFAULT_KEYS = {
|
|
14
17
|
"cockpit.shells.dock": "<leader>o",
|
|
15
18
|
"cockpit.shells.console": "<leader>i"
|
|
16
19
|
};
|
|
20
|
+
|
|
21
|
+
/** Interface settings; the `ui` section of the config file (see core/config.ts). */
|
|
22
|
+
|
|
17
23
|
const SHELL_PACKAGE = "@opencode-cockpit/shell";
|
|
24
|
+
const _PACKAGE_NAME = pkg.name;
|
|
18
25
|
|
|
19
26
|
/** Shell's TUI half as a factory, so bundles such as `opencode-cockpit` can include it. */
|
|
20
27
|
export function createShellTui({
|
|
@@ -36,8 +43,10 @@ export function createShellTui({
|
|
|
36
43
|
await shellTui(api, rawOptions, meta);
|
|
37
44
|
};
|
|
38
45
|
}
|
|
39
|
-
const shellTui = async (api, rawOptions) => {
|
|
40
|
-
|
|
46
|
+
const shellTui = async (api, rawOptions, meta) => {
|
|
47
|
+
// Settings come from the shared config file; plugin-entry options still win, flat or under "ui".
|
|
48
|
+
const config = loadConfig(api.state.path.directory, rawOptions);
|
|
49
|
+
const options = config.ui ?? {};
|
|
41
50
|
const client = createClient("opencode-cockpit/tui");
|
|
42
51
|
const store = createShellStore(api, client, {
|
|
43
52
|
historyMinutes: options.historyMinutes
|
|
@@ -65,6 +74,12 @@ const shellTui = async (api, rawOptions) => {
|
|
|
65
74
|
api: api,
|
|
66
75
|
store: store,
|
|
67
76
|
typing: typing,
|
|
77
|
+
get colors() {
|
|
78
|
+
return options.colors;
|
|
79
|
+
},
|
|
80
|
+
get defaultView() {
|
|
81
|
+
return options.defaultView;
|
|
82
|
+
},
|
|
68
83
|
onClose: () => api.ui.dialog.clear(),
|
|
69
84
|
onNewShell: () => newShell(api, store, openConsole)
|
|
70
85
|
}), () => {});
|
|
@@ -109,6 +124,13 @@ const shellTui = async (api, rawOptions) => {
|
|
|
109
124
|
message: String(err)
|
|
110
125
|
}));
|
|
111
126
|
}
|
|
127
|
+
}, {
|
|
128
|
+
name: "cockpit.shells.update",
|
|
129
|
+
title: "Update opencode-cockpit",
|
|
130
|
+
category: "Shells",
|
|
131
|
+
namespace: "palette",
|
|
132
|
+
slashName: "cockpit-update",
|
|
133
|
+
run: () => offerUpdate(api, meta, pkg.version)
|
|
112
134
|
}, {
|
|
113
135
|
name: "cockpit.shells.restartDaemon",
|
|
114
136
|
title: "Restart shell daemon",
|
|
@@ -136,6 +158,9 @@ const shellTui = async (api, rawOptions) => {
|
|
|
136
158
|
get height() {
|
|
137
159
|
return height();
|
|
138
160
|
},
|
|
161
|
+
get colors() {
|
|
162
|
+
return options.colors;
|
|
163
|
+
},
|
|
139
164
|
hint: () => `${shortcut("cockpit.shells.console")} console · ${shortcut("cockpit.shells.dock")} hide`,
|
|
140
165
|
onOpenConsole: id => openConsole(id)
|
|
141
166
|
}) : null);
|
|
@@ -154,6 +179,9 @@ const shellTui = async (api, rawOptions) => {
|
|
|
154
179
|
}
|
|
155
180
|
});
|
|
156
181
|
|
|
182
|
+
// OpenCode never re-resolves an installed plugin spec, so check for a newer release ourselves.
|
|
183
|
+
if (options.updateCheck !== false) void announceUpdate(api, meta, pkg.version);
|
|
184
|
+
|
|
157
185
|
// A daemon from older plugin code is kept only while it runs shells; say so once.
|
|
158
186
|
const offOutdated = client.onOutdated(info => {
|
|
159
187
|
if (!info) return;
|
|
@@ -170,93 +198,6 @@ const shellTui = async (api, rawOptions) => {
|
|
|
170
198
|
client.close();
|
|
171
199
|
});
|
|
172
200
|
};
|
|
173
|
-
function newShell(api, store, open) {
|
|
174
|
-
const DialogPrompt = api.ui.DialogPrompt;
|
|
175
|
-
api.ui.dialog.replace(() => _$createComponent(DialogPrompt, {
|
|
176
|
-
title: "New background shell",
|
|
177
|
-
placeholder: "npm run dev",
|
|
178
|
-
onConfirm: value => {
|
|
179
|
-
const command = value.trim();
|
|
180
|
-
if (!command) return api.ui.dialog.clear();
|
|
181
|
-
const shell = process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash";
|
|
182
|
-
store.client.call("shell.start", {
|
|
183
|
-
command: shell,
|
|
184
|
-
args: ["-c", command],
|
|
185
|
-
cwd: store.project(),
|
|
186
|
-
title: command.slice(0, 60),
|
|
187
|
-
owner: {
|
|
188
|
-
project: store.project()
|
|
189
|
-
},
|
|
190
|
-
reuse: true
|
|
191
|
-
}).then(info => {
|
|
192
|
-
void store.refresh();
|
|
193
|
-
open(info.id);
|
|
194
|
-
}).catch(err => {
|
|
195
|
-
api.ui.dialog.clear();
|
|
196
|
-
api.ui.toast({
|
|
197
|
-
variant: "error",
|
|
198
|
-
title: "Shell",
|
|
199
|
-
message: err instanceof Error ? err.message : String(err)
|
|
200
|
-
});
|
|
201
|
-
});
|
|
202
|
-
},
|
|
203
|
-
onCancel: () => api.ui.dialog.clear()
|
|
204
|
-
}));
|
|
205
|
-
}
|
|
206
|
-
function restartDaemon(api, store) {
|
|
207
|
-
const running = store.shells().filter(s => s.status === "running").length;
|
|
208
|
-
const restart = force => {
|
|
209
|
-
api.ui.dialog.clear();
|
|
210
|
-
store.client.restartDaemon({
|
|
211
|
-
force
|
|
212
|
-
}).then(ok => {
|
|
213
|
-
void store.refresh();
|
|
214
|
-
api.ui.toast({
|
|
215
|
-
variant: ok ? "success" : "warning",
|
|
216
|
-
title: "Shells",
|
|
217
|
-
message: ok ? "Shell daemon restarted" : "Shells are running; restart was not forced"
|
|
218
|
-
});
|
|
219
|
-
}).catch(err => api.ui.toast({
|
|
220
|
-
variant: "error",
|
|
221
|
-
title: "Shells",
|
|
222
|
-
message: String(err)
|
|
223
|
-
}));
|
|
224
|
-
};
|
|
225
|
-
if (running === 0) return restart(false);
|
|
226
|
-
const DialogConfirm = api.ui.DialogConfirm;
|
|
227
|
-
api.ui.dialog.replace(() => _$createComponent(DialogConfirm, {
|
|
228
|
-
title: "Restart shell daemon?",
|
|
229
|
-
message: `${running} running shell${running === 1 ? "" : "s"} will be stopped.`,
|
|
230
|
-
onConfirm: () => restart(true),
|
|
231
|
-
onCancel: () => api.ui.dialog.clear()
|
|
232
|
-
}));
|
|
233
|
-
}
|
|
234
|
-
function pickShell(api, store, open) {
|
|
235
|
-
const DialogSelect = api.ui.DialogSelect;
|
|
236
|
-
const shells = order(store.shells());
|
|
237
|
-
if (shells.length === 0) {
|
|
238
|
-
api.ui.toast({
|
|
239
|
-
variant: "info",
|
|
240
|
-
title: "Shells",
|
|
241
|
-
message: "No shells in this project yet"
|
|
242
|
-
});
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
api.ui.dialog.replace(() => _$createComponent(DialogSelect, {
|
|
246
|
-
title: "Shells",
|
|
247
|
-
get current() {
|
|
248
|
-
return store.selected()?.id;
|
|
249
|
-
},
|
|
250
|
-
get options() {
|
|
251
|
-
return shells.map(s => ({
|
|
252
|
-
title: s.title,
|
|
253
|
-
value: s.id,
|
|
254
|
-
description: `${BADGE_LABEL[kindOf(s)]} · ${displayCommand(s).slice(0, 60)}`
|
|
255
|
-
}));
|
|
256
|
-
},
|
|
257
|
-
onSelect: option => open(option.value)
|
|
258
|
-
}));
|
|
259
|
-
}
|
|
260
201
|
const plugin = {
|
|
261
202
|
id: "opencode-cockpit.shell",
|
|
262
203
|
tui: createShellTui()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { kindOfShell } from "../../core/kind.js";
|
|
2
|
+
import { displayCommand, statusDetail, truncate, wrapText } from "./view.js";
|
|
3
|
+
|
|
4
|
+
/** Everything known about one shell, for the console's details view. */
|
|
5
|
+
export function detailRows(s, now, cols) {
|
|
6
|
+
const width = Math.max(10, cols - 11);
|
|
7
|
+
const rows = [];
|
|
8
|
+
for (const [i, line] of wrapText(displayCommand(s), width, 12).entries()) rows.push([i === 0 ? "command" : "", line]);
|
|
9
|
+
rows.push(["kind", kindOfShell(s)]);
|
|
10
|
+
rows.push(["folder", s.cwd]);
|
|
11
|
+
rows.push(["status", `${s.status}${s.exitCode !== undefined ? ` (exit ${s.exitCode})` : ""}${s.signal ? ` (${s.signal})` : ""}`]);
|
|
12
|
+
rows.push(["timing", statusDetail(s, now)]);
|
|
13
|
+
rows.push(["started", new Date(s.startedAt).toLocaleString()]);
|
|
14
|
+
if (s.summary) rows.push(["summary", truncate(s.summary, width)]);
|
|
15
|
+
rows.push(["id", `${s.id} · run ${s.run}${s.pid ? ` · pid ${s.pid}` : ""}`]);
|
|
16
|
+
rows.push(["owner", s.owner.session ? `agent session ${s.owner.session}` : "you"]);
|
|
17
|
+
if (s.watch) {
|
|
18
|
+
rows.push(["watch", `${s.watch.preset ?? "custom rule"} · ${s.watch.status} · ${s.watch.runs} run${s.watch.runs === 1 ? "" : "s"}${s.watch.summary ? ` · ${truncate(s.watch.summary, width - 30)}` : ""}`]);
|
|
19
|
+
}
|
|
20
|
+
rows.push(["output", `${s.lines.last} lines · ${Math.round(s.bytes / 1024)} KiB`]);
|
|
21
|
+
if (s.logFile) rows.push(["log file", s.logFile]);
|
|
22
|
+
return rows;
|
|
23
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Log search helpers: what matched, and how failures should read. */
|
|
2
|
+
export function splitMatches(text, query) {
|
|
3
|
+
if (!query) return [{
|
|
4
|
+
text,
|
|
5
|
+
match: false
|
|
6
|
+
}];
|
|
7
|
+
const parts = [];
|
|
8
|
+
const haystack = text.toLowerCase();
|
|
9
|
+
const needle = query.toLowerCase();
|
|
10
|
+
let from = 0;
|
|
11
|
+
for (let at = haystack.indexOf(needle, from); at !== -1; at = haystack.indexOf(needle, from)) {
|
|
12
|
+
if (at > from) parts.push({
|
|
13
|
+
text: text.slice(from, at),
|
|
14
|
+
match: false
|
|
15
|
+
});
|
|
16
|
+
parts.push({
|
|
17
|
+
text: text.slice(at, at + needle.length),
|
|
18
|
+
match: true
|
|
19
|
+
});
|
|
20
|
+
from = at + needle.length;
|
|
21
|
+
}
|
|
22
|
+
if (from < text.length) parts.push({
|
|
23
|
+
text: text.slice(from),
|
|
24
|
+
match: false
|
|
25
|
+
});
|
|
26
|
+
return parts.length > 0 ? parts : [{
|
|
27
|
+
text,
|
|
28
|
+
match: false
|
|
29
|
+
}];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Daemon errors name ids and internal states; say what happened instead. */
|
|
33
|
+
export function friendlyError(err) {
|
|
34
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
35
|
+
if (/is (exited|killed|failed)$/.test(message)) return "the shell is no longer running";
|
|
36
|
+
if (/not found$/.test(message)) return "that shell was already removed";
|
|
37
|
+
if (/connection|not running|did not start/.test(message)) return "lost connection to cockpitd, retrying";
|
|
38
|
+
return message;
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update notice.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode resolves an unpinned plugin spec (`@latest`) once and then reuses its cached copy, so
|
|
5
|
+
* an install never moves forward on its own. The plugin therefore checks the registry itself, at
|
|
6
|
+
* most once a day, and offers to clear its own cache entry so the next start installs the newer
|
|
7
|
+
* version.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
export function shouldCheck(lastCheckedAt, now, everyMs = DAY_MS) {
|
|
12
|
+
return lastCheckedAt === undefined || now - lastCheckedAt >= everyMs;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** True when `latest` is a higher release than `current`. Pre-releases never look newer. */
|
|
16
|
+
export function isNewer(latest, current) {
|
|
17
|
+
const parse = v => {
|
|
18
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([\w.]+))?$/.exec(v.trim());
|
|
19
|
+
return match ? {
|
|
20
|
+
nums: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
21
|
+
pre: match[4]
|
|
22
|
+
} : undefined;
|
|
23
|
+
};
|
|
24
|
+
const a = parse(latest);
|
|
25
|
+
const b = parse(current);
|
|
26
|
+
if (!a || !b) return false;
|
|
27
|
+
for (let i = 0; i < 3; i++) {
|
|
28
|
+
const diff = a.nums[i] - b.nums[i];
|
|
29
|
+
if (diff !== 0) return diff > 0;
|
|
30
|
+
}
|
|
31
|
+
// Same numbers: a release beats the pre-release of that version, never the other way round.
|
|
32
|
+
return a.pre === undefined && b.pre !== undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The directory OpenCode installed this plugin into, given the resolved module path. Removing it
|
|
37
|
+
* makes the next start reinstall from the registry. Undefined for anything but an npm install.
|
|
38
|
+
*/
|
|
39
|
+
export function cacheDirFor(target, source) {
|
|
40
|
+
if (source !== "npm" || !target) return undefined;
|
|
41
|
+
const [dir] = target.split(`${"/"}node_modules${"/"}`);
|
|
42
|
+
return dir && dir !== target ? dir : undefined;
|
|
43
|
+
}
|
|
44
|
+
export async function fetchLatestVersion(pkg, timeoutMs = 3000) {
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(`https://registry.npmjs.org/${pkg}/latest`, {
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
48
|
+
headers: {
|
|
49
|
+
accept: "application/vnd.npm.install-v1+json"
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) return undefined;
|
|
53
|
+
const body = await response.json();
|
|
54
|
+
return body.version;
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined; // offline, rate limited, private registry: never worth bothering the user
|
|
57
|
+
}
|
|
58
|
+
}
|