@opencode-cockpit/shell 0.1.4 → 0.2.0
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 +100 -10
- package/dist/agent/plugin.js +180 -0
- package/dist/agent/tools/index.js +23 -0
- package/{src/tools/keys.ts → dist/agent/tools/keys.js} +11 -14
- 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 +140 -0
- package/dist/agent/tools/stop.js +42 -0
- package/dist/agent/tools/wait.js +79 -0
- package/dist/agent/tools/watch-args.js +12 -0
- package/dist/agent/tools/watch.js +74 -0
- package/dist/connect.js +31 -0
- package/dist/core/config.js +97 -0
- package/dist/core/find.js +69 -0
- package/dist/core/format.js +63 -0
- package/dist/core/kind.js +31 -0
- package/dist/server.js +2 -0
- package/dist/tui/components/badge.js +29 -0
- package/dist/tui/components/console.js +759 -0
- package/dist/tui/components/dock.js +270 -0
- package/dist/tui/components/sidebar.js +136 -0
- package/dist/tui/dialogs.js +163 -0
- package/dist/tui/index.js +205 -0
- package/dist/tui/lib/details.js +23 -0
- package/dist/tui/lib/keys.js +43 -0
- package/dist/tui/lib/search.js +39 -0
- package/dist/tui/lib/update.js +58 -0
- package/dist/tui/lib/view.js +191 -0
- package/dist/tui/state/store.js +158 -0
- package/package.json +21 -12
- package/types/agent/plugin.d.ts +12 -0
- package/types/agent/tools/index.d.ts +5 -0
- package/types/agent/tools/keys.d.ts +3 -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 +10 -0
- package/types/agent/tools/watch.d.ts +3 -0
- package/types/connect.d.ts +9 -0
- package/types/core/config.d.ts +59 -0
- package/types/core/find.d.ts +37 -0
- package/types/core/format.d.ts +8 -0
- package/types/core/kind.d.ts +9 -0
- package/types/server.d.ts +2 -0
- package/types/tui/components/badge.d.ts +9 -0
- package/types/tui/components/console.d.ts +22 -0
- package/types/tui/components/dock.d.ts +14 -0
- package/types/tui/components/sidebar.d.ts +14 -0
- package/types/tui/dialogs.d.ts +10 -0
- package/types/tui/index.d.ts +13 -0
- package/types/tui/lib/details.d.ts +3 -0
- package/types/tui/lib/keys.d.ts +8 -0
- package/types/tui/lib/search.d.ts +7 -0
- package/types/tui/lib/update.d.ts +25 -0
- package/types/tui/lib/view.d.ts +80 -0
- package/types/tui/state/store.d.ts +55 -0
- package/src/connect.ts +0 -24
- package/src/server.ts +0 -156
- package/src/tools/find.ts +0 -80
- package/src/tools/format.ts +0 -78
- package/src/tools/index.ts +0 -467
- package/src/tui/badge.tsx +0 -17
- package/src/tui/console.tsx +0 -438
- package/src/tui/dock.tsx +0 -130
- package/src/tui/index.tsx +0 -286
- package/src/tui/keys.ts +0 -52
- package/src/tui/sidebar.tsx +0 -44
- package/src/tui/store.ts +0 -185
- package/src/tui/view.ts +0 -161
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
2
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
3
|
+
/** @jsxImportSource @opentui/solid */
|
|
4
|
+
|
|
5
|
+
import { createBindingLookup } from "@opencode-ai/plugin/tui";
|
|
6
|
+
import { claimFeature, duplicateFeatureMessage } from "@opencode-cockpit/client";
|
|
7
|
+
import { createSignal } from "solid-js";
|
|
8
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
9
|
+
import { createClient } from "../connect.js";
|
|
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";
|
|
16
|
+
const DEFAULT_KEYS = {
|
|
17
|
+
"cockpit.shells.dock": "<leader>o",
|
|
18
|
+
"cockpit.shells.console": "<leader>i"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Interface settings; the `ui` section of the config file (see core/config.ts). */
|
|
22
|
+
|
|
23
|
+
const SHELL_PACKAGE = "@opencode-cockpit/shell";
|
|
24
|
+
const _PACKAGE_NAME = pkg.name;
|
|
25
|
+
|
|
26
|
+
/** Shell's TUI half as a factory, so bundles such as `opencode-cockpit` can include it. */
|
|
27
|
+
export function createShellTui({
|
|
28
|
+
source = SHELL_PACKAGE
|
|
29
|
+
} = {}) {
|
|
30
|
+
return async (api, rawOptions, meta) => {
|
|
31
|
+
// The renderer is shared by every TUI plugin in this OpenCode window.
|
|
32
|
+
const claim = claimFeature(api.renderer, "shell", source);
|
|
33
|
+
if (!claim.active) {
|
|
34
|
+
api.ui.toast({
|
|
35
|
+
variant: "warning",
|
|
36
|
+
title: "opencode-cockpit",
|
|
37
|
+
message: duplicateFeatureMessage("Shell", claim.owner, source),
|
|
38
|
+
duration: 10_000
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
api.lifecycle.onDispose(() => claim.release());
|
|
43
|
+
await shellTui(api, rawOptions, meta);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
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 ?? {};
|
|
50
|
+
const client = createClient("opencode-cockpit/tui");
|
|
51
|
+
const store = createShellStore(api, client, {
|
|
52
|
+
historyMinutes: options.historyMinutes
|
|
53
|
+
});
|
|
54
|
+
const keys = createBindingLookup({
|
|
55
|
+
...DEFAULT_KEYS,
|
|
56
|
+
...options.keybinds
|
|
57
|
+
});
|
|
58
|
+
const [dockOpen, setDockOpen] = createSignal(api.kv.get("cockpit.dock.open", options.dockOpen ?? false));
|
|
59
|
+
const toggleDock = () => {
|
|
60
|
+
const next = !dockOpen();
|
|
61
|
+
setDockOpen(next);
|
|
62
|
+
api.kv.set("cockpit.dock.open", next);
|
|
63
|
+
};
|
|
64
|
+
const shortcut = command => {
|
|
65
|
+
const bindings = api.keymap.getCommandBindings({
|
|
66
|
+
visibility: "registered",
|
|
67
|
+
commands: [command]
|
|
68
|
+
});
|
|
69
|
+
return api.keys.formatBindings(bindings.get(command)) ?? "";
|
|
70
|
+
};
|
|
71
|
+
const openConsole = (id, typing = false) => {
|
|
72
|
+
if (id) store.select(id);
|
|
73
|
+
api.ui.dialog.replace(() => _$createComponent(Console, {
|
|
74
|
+
api: api,
|
|
75
|
+
store: store,
|
|
76
|
+
typing: typing,
|
|
77
|
+
get colors() {
|
|
78
|
+
return options.colors;
|
|
79
|
+
},
|
|
80
|
+
get defaultView() {
|
|
81
|
+
return options.defaultView;
|
|
82
|
+
},
|
|
83
|
+
onClose: () => api.ui.dialog.clear(),
|
|
84
|
+
onNewShell: () => newShell(api, store, openConsole)
|
|
85
|
+
}), () => {});
|
|
86
|
+
api.ui.dialog.setSize("xlarge");
|
|
87
|
+
};
|
|
88
|
+
api.keymap.registerLayer({
|
|
89
|
+
commands: [{
|
|
90
|
+
name: "cockpit.shells.dock",
|
|
91
|
+
title: "Toggle shells panel",
|
|
92
|
+
category: "Shells",
|
|
93
|
+
namespace: "palette",
|
|
94
|
+
slashName: "shells",
|
|
95
|
+
run: () => toggleDock()
|
|
96
|
+
}, {
|
|
97
|
+
name: "cockpit.shells.console",
|
|
98
|
+
title: "Open shell console",
|
|
99
|
+
category: "Shells",
|
|
100
|
+
namespace: "palette",
|
|
101
|
+
slashName: "shell",
|
|
102
|
+
run: () => openConsole()
|
|
103
|
+
}, {
|
|
104
|
+
name: "cockpit.shells.new",
|
|
105
|
+
title: "New background shell",
|
|
106
|
+
category: "Shells",
|
|
107
|
+
namespace: "palette",
|
|
108
|
+
slashName: "shell-new",
|
|
109
|
+
run: () => newShell(api, store, openConsole)
|
|
110
|
+
}, {
|
|
111
|
+
name: "cockpit.shells.clear",
|
|
112
|
+
title: "Clear finished shells",
|
|
113
|
+
category: "Shells",
|
|
114
|
+
namespace: "palette",
|
|
115
|
+
slashName: "shells-clear",
|
|
116
|
+
run: () => {
|
|
117
|
+
void store.clearFinished().then(n => api.ui.toast({
|
|
118
|
+
variant: "success",
|
|
119
|
+
title: "Shells",
|
|
120
|
+
message: `Cleared ${n} finished shell${n === 1 ? "" : "s"}`
|
|
121
|
+
})).catch(err => api.ui.toast({
|
|
122
|
+
variant: "error",
|
|
123
|
+
title: "Shells",
|
|
124
|
+
message: String(err)
|
|
125
|
+
}));
|
|
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)
|
|
134
|
+
}, {
|
|
135
|
+
name: "cockpit.shells.restartDaemon",
|
|
136
|
+
title: "Restart shell daemon",
|
|
137
|
+
category: "Shells",
|
|
138
|
+
namespace: "palette",
|
|
139
|
+
slashName: "shells-restart-daemon",
|
|
140
|
+
run: () => restartDaemon(api, store)
|
|
141
|
+
}, {
|
|
142
|
+
name: "cockpit.shells.pick",
|
|
143
|
+
title: "Switch shell",
|
|
144
|
+
category: "Shells",
|
|
145
|
+
namespace: "palette",
|
|
146
|
+
run: () => pickShell(api, store, openConsole)
|
|
147
|
+
}],
|
|
148
|
+
bindings: keys.gather("cockpit", Object.keys(DEFAULT_KEYS))
|
|
149
|
+
});
|
|
150
|
+
const height = () => Math.max(6, Math.min(options.dockHeight ?? 14, Math.floor(api.renderer.height * 0.45)));
|
|
151
|
+
api.slots.register({
|
|
152
|
+
order: 150,
|
|
153
|
+
slots: {
|
|
154
|
+
app_bottom() {
|
|
155
|
+
return _$memo(() => _$memo(() => !!dockOpen())() ? _$createComponent(Dock, {
|
|
156
|
+
api: api,
|
|
157
|
+
store: store,
|
|
158
|
+
get height() {
|
|
159
|
+
return height();
|
|
160
|
+
},
|
|
161
|
+
get colors() {
|
|
162
|
+
return options.colors;
|
|
163
|
+
},
|
|
164
|
+
hint: () => `${shortcut("cockpit.shells.console")} console · ${shortcut("cockpit.shells.dock")} hide`,
|
|
165
|
+
onOpenConsole: id => openConsole(id)
|
|
166
|
+
}) : null);
|
|
167
|
+
},
|
|
168
|
+
sidebar_content() {
|
|
169
|
+
return _$createComponent(SidebarShells, {
|
|
170
|
+
api: api,
|
|
171
|
+
store: store,
|
|
172
|
+
get rows() {
|
|
173
|
+
return options.sidebarRows;
|
|
174
|
+
},
|
|
175
|
+
onOpen: id => openConsole(id),
|
|
176
|
+
consoleShortcut: () => shortcut("cockpit.shells.console")
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
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
|
+
|
|
185
|
+
// A daemon from older plugin code is kept only while it runs shells; say so once.
|
|
186
|
+
const offOutdated = client.onOutdated(info => {
|
|
187
|
+
if (!info) return;
|
|
188
|
+
api.ui.toast({
|
|
189
|
+
variant: "warning",
|
|
190
|
+
title: "Shells",
|
|
191
|
+
message: "The shell daemon is running older code because shells are still running. Run /shells-restart-daemon when convenient.",
|
|
192
|
+
duration: 8000
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
api.lifecycle.onDispose(() => {
|
|
196
|
+
offOutdated();
|
|
197
|
+
store.dispose();
|
|
198
|
+
client.close();
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
const plugin = {
|
|
202
|
+
id: "opencode-cockpit.shell",
|
|
203
|
+
tui: createShellTui()
|
|
204
|
+
};
|
|
205
|
+
export default plugin;
|
|
@@ -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,43 @@
|
|
|
1
|
+
const NAMED = {
|
|
2
|
+
return: "\r",
|
|
3
|
+
enter: "\r",
|
|
4
|
+
linefeed: "\n",
|
|
5
|
+
tab: "\t",
|
|
6
|
+
backspace: "\x7f",
|
|
7
|
+
escape: "\x1b",
|
|
8
|
+
space: " ",
|
|
9
|
+
up: "\x1b[A",
|
|
10
|
+
down: "\x1b[B",
|
|
11
|
+
right: "\x1b[C",
|
|
12
|
+
left: "\x1b[D",
|
|
13
|
+
home: "\x1b[H",
|
|
14
|
+
end: "\x1b[F",
|
|
15
|
+
pageup: "\x1b[5~",
|
|
16
|
+
pagedown: "\x1b[6~",
|
|
17
|
+
delete: "\x1b[3~",
|
|
18
|
+
insert: "\x1b[2~"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Re-encodes a parsed key as legacy terminal bytes. The TUI may receive keys via the kitty
|
|
23
|
+
* protocol, whose raw form programs in the PTY would not understand.
|
|
24
|
+
*/
|
|
25
|
+
export function keyToBytes(e) {
|
|
26
|
+
if (e.eventType === "release") return undefined;
|
|
27
|
+
const name = e.name?.toLowerCase() ?? "";
|
|
28
|
+
if (e.name === "tab" && e.shift) return "\x1b[Z";
|
|
29
|
+
const named = NAMED[name];
|
|
30
|
+
if (named !== undefined) return e.meta || e.option ? `\x1b${named}` : named;
|
|
31
|
+
if (e.ctrl && name.length === 1) {
|
|
32
|
+
const code = name.toUpperCase().charCodeAt(0);
|
|
33
|
+
if (code >= 64 && code <= 95) return String.fromCharCode(code - 64);
|
|
34
|
+
}
|
|
35
|
+
const text = e.sequence && !e.sequence.startsWith("\x1b") ? e.sequence : name.length === 1 ? e.shift ? name.toUpperCase() : name : undefined;
|
|
36
|
+
if (!text) return undefined;
|
|
37
|
+
return e.meta || e.option ? `\x1b${text}` : text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** ctrl+] releases typing mode (the telnet escape), since esc and ctrl+c belong to the program. */
|
|
41
|
+
export function isReleaseKey(e) {
|
|
42
|
+
return e.ctrl && (e.name === "]" || e.sequence === "\x1d");
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { duration } from "../../core/format.js";
|
|
2
|
+
|
|
3
|
+
/** What a human cares about, derived from status + exit code so every surface agrees. */
|
|
4
|
+
|
|
5
|
+
export function kindOf(s) {
|
|
6
|
+
if (s.status === "running") return "run";
|
|
7
|
+
if (s.status === "killed") return "stop";
|
|
8
|
+
if (s.status === "exited" && s.exitCode === 0) return "done";
|
|
9
|
+
return "fail";
|
|
10
|
+
}
|
|
11
|
+
export const BADGE_LABEL = {
|
|
12
|
+
run: "RUN",
|
|
13
|
+
fail: "FAIL",
|
|
14
|
+
stop: "STOP",
|
|
15
|
+
done: "DONE"
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Braille spinner: single-width in every terminal font. */
|
|
19
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
20
|
+
export function kindColor(theme, kind) {
|
|
21
|
+
switch (kind) {
|
|
22
|
+
case "run":
|
|
23
|
+
return theme.success;
|
|
24
|
+
case "fail":
|
|
25
|
+
return theme.error;
|
|
26
|
+
case "stop":
|
|
27
|
+
return theme.warning;
|
|
28
|
+
case "done":
|
|
29
|
+
return theme.textMuted;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fixed 7-column pill so lists line up: " ⠹ RUN ", " FAIL ". */
|
|
34
|
+
export function badgeText(kind, frame = 0) {
|
|
35
|
+
if (kind === "run") return ` ${SPINNER[frame % SPINNER.length]} RUN `;
|
|
36
|
+
return ` ${BADGE_LABEL[kind].padEnd(4)} `;
|
|
37
|
+
}
|
|
38
|
+
const RANK = {
|
|
39
|
+
run: 0,
|
|
40
|
+
fail: 1,
|
|
41
|
+
stop: 2,
|
|
42
|
+
done: 3
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Running first (oldest first, stable tabs), then failures, stopped, done (most recent first). */
|
|
46
|
+
export function order(list) {
|
|
47
|
+
return [...list].sort((a, b) => {
|
|
48
|
+
const ka = kindOf(a);
|
|
49
|
+
const kb = kindOf(b);
|
|
50
|
+
if (ka !== kb) return RANK[ka] - RANK[kb];
|
|
51
|
+
if (ka === "run") return a.startedAt - b.startedAt;
|
|
52
|
+
return (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Default view: running shells and recent failures. Everything else folds into a "N more" chip. */
|
|
56
|
+
export function partition(list, opts) {
|
|
57
|
+
const ordered = order(list);
|
|
58
|
+
if (opts.showAll) return {
|
|
59
|
+
visible: ordered,
|
|
60
|
+
hidden: []
|
|
61
|
+
};
|
|
62
|
+
const visible = [];
|
|
63
|
+
const hidden = [];
|
|
64
|
+
for (const s of ordered) {
|
|
65
|
+
const kind = kindOf(s);
|
|
66
|
+
const recent = opts.now - (s.endedAt ?? opts.now) <= opts.historyMs;
|
|
67
|
+
if (kind === "run" || kind === "fail" && recent || s.id === opts.keep) visible.push(s);else hidden.push(s);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
visible,
|
|
71
|
+
hidden
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function statusDetail(s, now) {
|
|
75
|
+
const kind = kindOf(s);
|
|
76
|
+
const ran = duration((s.endedAt ?? now) - s.startedAt);
|
|
77
|
+
const ago = s.endedAt ? since(now - s.endedAt) : "";
|
|
78
|
+
switch (kind) {
|
|
79
|
+
case "run":
|
|
80
|
+
return ran;
|
|
81
|
+
case "done":
|
|
82
|
+
return `took ${ran} · ${ago}`;
|
|
83
|
+
case "stop":
|
|
84
|
+
return `stopped after ${ran} · ${ago}`;
|
|
85
|
+
case "fail":
|
|
86
|
+
if (s.status === "failed") return "could not start";
|
|
87
|
+
return `exit ${s.exitCode ?? "?"} after ${ran} · ${ago}`;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Short health label for a watched shell, e.g. "tsc ✗" — empty when nothing is watching it. */
|
|
92
|
+
export function watchLabel(s) {
|
|
93
|
+
const watch = s.watch;
|
|
94
|
+
if (!watch || watch.status === "pending") return "";
|
|
95
|
+
const mark = watch.status === "ok" ? "✓" : watch.status === "fail" ? "✗" : "?";
|
|
96
|
+
return `${watch.preset ?? "watch"} ${mark}`;
|
|
97
|
+
}
|
|
98
|
+
export function watchColor(theme, s) {
|
|
99
|
+
switch (s.watch?.status) {
|
|
100
|
+
case "ok":
|
|
101
|
+
return theme.success;
|
|
102
|
+
case "fail":
|
|
103
|
+
return theme.error;
|
|
104
|
+
default:
|
|
105
|
+
return theme.textMuted;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Compact detail for narrow lists. */
|
|
110
|
+
export function shortDetail(s, now) {
|
|
111
|
+
switch (kindOf(s)) {
|
|
112
|
+
case "run":
|
|
113
|
+
return duration(now - s.startedAt);
|
|
114
|
+
case "fail":
|
|
115
|
+
return s.status === "failed" ? "no start" : `exit ${s.exitCode ?? "?"}`;
|
|
116
|
+
case "stop":
|
|
117
|
+
return "stopped";
|
|
118
|
+
case "done":
|
|
119
|
+
return s.endedAt ? since(now - s.endedAt) : "done";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Coarse relative time: "just now", "12s ago", "9m ago", "3h ago". */
|
|
124
|
+
export function since(ms) {
|
|
125
|
+
const s = Math.floor(ms / 1000);
|
|
126
|
+
if (s < 5) return "just now";
|
|
127
|
+
if (s < 60) return `${s}s ago`;
|
|
128
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
129
|
+
if (s < 86_400) return `${Math.floor(s / 3600)}h ago`;
|
|
130
|
+
return `${Math.floor(s / 86_400)}d ago`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The command as the user or agent wrote it, without the `$SHELL -c` wrapper. */
|
|
134
|
+
export function displayCommand(s) {
|
|
135
|
+
if (s.args.length === 2 && s.args[0] === "-c" && /(^|\/)(ba|z|fi|da|k)?sh$/.test(s.command)) return s.args[1];
|
|
136
|
+
return [s.command, ...s.args].join(" ");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Folder relative to the project: "" at the root, "./sub" inside, "~/x" elsewhere under home. */
|
|
140
|
+
export function relativeCwd(cwd, project, home = process.env.HOME ?? "") {
|
|
141
|
+
const trim = p => p.replace(/\/+$/, "");
|
|
142
|
+
const c = trim(cwd);
|
|
143
|
+
const p = trim(project);
|
|
144
|
+
if (c === p) return "";
|
|
145
|
+
if (c.startsWith(`${p}/`)) return `./${c.slice(p.length + 1)}`;
|
|
146
|
+
if (home && c.startsWith(`${trim(home)}/`)) return `~/${c.slice(trim(home).length + 1)}`;
|
|
147
|
+
return c;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Hard-wraps to `width`, at most `maxLines`; the last line ends with … when text was cut. */
|
|
151
|
+
export function wrapText(text, width, maxLines) {
|
|
152
|
+
const flat = text.replace(/\s*\n\s*/g, " ⏎ ");
|
|
153
|
+
const w = Math.max(4, width);
|
|
154
|
+
const lines = [];
|
|
155
|
+
for (let i = 0; i < flat.length && lines.length < maxLines; i += w) lines.push(flat.slice(i, i + w));
|
|
156
|
+
if (lines.length === 0) return [""];
|
|
157
|
+
if (flat.length > w * maxLines) {
|
|
158
|
+
const last = lines[lines.length - 1];
|
|
159
|
+
lines[lines.length - 1] = `${last.slice(0, w - 1)}…`;
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Last `rows` styled rows, each cut to `cols`, so colour survives the same trimming as text. */
|
|
165
|
+
export function tailRuns(styled, rows, cols) {
|
|
166
|
+
if (!styled) return [];
|
|
167
|
+
return styled.slice(Math.max(0, styled.length - rows)).map(row => {
|
|
168
|
+
const out = [];
|
|
169
|
+
let width = 0;
|
|
170
|
+
for (const run of row) {
|
|
171
|
+
if (width >= cols) break;
|
|
172
|
+
const text = run.text.slice(0, cols - width);
|
|
173
|
+
width += text.length;
|
|
174
|
+
out.push({
|
|
175
|
+
...run,
|
|
176
|
+
text
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Last `rows` lines of screen text, each cut to `cols`. */
|
|
184
|
+
export function tailLines(text, rows, cols) {
|
|
185
|
+
if (!text) return "";
|
|
186
|
+
const lines = text.split("\n");
|
|
187
|
+
return lines.slice(Math.max(0, lines.length - rows)).map(l => l.length > cols ? `${l.slice(0, Math.max(0, cols - 1))}…` : l).join("\n");
|
|
188
|
+
}
|
|
189
|
+
export function truncate(text, max) {
|
|
190
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
|
|
191
|
+
}
|