@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.
Files changed (77) hide show
  1. package/README.md +100 -10
  2. package/dist/agent/plugin.js +180 -0
  3. package/dist/agent/tools/index.js +23 -0
  4. package/{src/tools/keys.ts → dist/agent/tools/keys.js} +11 -14
  5. package/dist/agent/tools/list.js +68 -0
  6. package/dist/agent/tools/read.js +59 -0
  7. package/dist/agent/tools/restart.js +44 -0
  8. package/dist/agent/tools/send.js +71 -0
  9. package/dist/agent/tools/shared.js +94 -0
  10. package/dist/agent/tools/start.js +140 -0
  11. package/dist/agent/tools/stop.js +42 -0
  12. package/dist/agent/tools/wait.js +79 -0
  13. package/dist/agent/tools/watch-args.js +12 -0
  14. package/dist/agent/tools/watch.js +74 -0
  15. package/dist/connect.js +31 -0
  16. package/dist/core/config.js +97 -0
  17. package/dist/core/find.js +69 -0
  18. package/dist/core/format.js +63 -0
  19. package/dist/core/kind.js +31 -0
  20. package/dist/server.js +2 -0
  21. package/dist/tui/components/badge.js +29 -0
  22. package/dist/tui/components/console.js +759 -0
  23. package/dist/tui/components/dock.js +270 -0
  24. package/dist/tui/components/sidebar.js +136 -0
  25. package/dist/tui/dialogs.js +163 -0
  26. package/dist/tui/index.js +205 -0
  27. package/dist/tui/lib/details.js +23 -0
  28. package/dist/tui/lib/keys.js +43 -0
  29. package/dist/tui/lib/search.js +39 -0
  30. package/dist/tui/lib/update.js +58 -0
  31. package/dist/tui/lib/view.js +191 -0
  32. package/dist/tui/state/store.js +158 -0
  33. package/package.json +21 -12
  34. package/types/agent/plugin.d.ts +12 -0
  35. package/types/agent/tools/index.d.ts +5 -0
  36. package/types/agent/tools/keys.d.ts +3 -0
  37. package/types/agent/tools/list.d.ts +3 -0
  38. package/types/agent/tools/read.d.ts +3 -0
  39. package/types/agent/tools/restart.d.ts +3 -0
  40. package/types/agent/tools/send.d.ts +3 -0
  41. package/types/agent/tools/shared.d.ts +40 -0
  42. package/types/agent/tools/start.d.ts +3 -0
  43. package/types/agent/tools/stop.d.ts +3 -0
  44. package/types/agent/tools/wait.d.ts +3 -0
  45. package/types/agent/tools/watch-args.d.ts +10 -0
  46. package/types/agent/tools/watch.d.ts +3 -0
  47. package/types/connect.d.ts +9 -0
  48. package/types/core/config.d.ts +59 -0
  49. package/types/core/find.d.ts +37 -0
  50. package/types/core/format.d.ts +8 -0
  51. package/types/core/kind.d.ts +9 -0
  52. package/types/server.d.ts +2 -0
  53. package/types/tui/components/badge.d.ts +9 -0
  54. package/types/tui/components/console.d.ts +22 -0
  55. package/types/tui/components/dock.d.ts +14 -0
  56. package/types/tui/components/sidebar.d.ts +14 -0
  57. package/types/tui/dialogs.d.ts +10 -0
  58. package/types/tui/index.d.ts +13 -0
  59. package/types/tui/lib/details.d.ts +3 -0
  60. package/types/tui/lib/keys.d.ts +8 -0
  61. package/types/tui/lib/search.d.ts +7 -0
  62. package/types/tui/lib/update.d.ts +25 -0
  63. package/types/tui/lib/view.d.ts +80 -0
  64. package/types/tui/state/store.d.ts +55 -0
  65. package/src/connect.ts +0 -24
  66. package/src/server.ts +0 -156
  67. package/src/tools/find.ts +0 -80
  68. package/src/tools/format.ts +0 -78
  69. package/src/tools/index.ts +0 -467
  70. package/src/tui/badge.tsx +0 -17
  71. package/src/tui/console.tsx +0 -438
  72. package/src/tui/dock.tsx +0 -130
  73. package/src/tui/index.tsx +0 -286
  74. package/src/tui/keys.ts +0 -52
  75. package/src/tui/sidebar.tsx +0 -44
  76. package/src/tui/store.ts +0 -185
  77. 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
+ }