@opencode-cockpit/shell 0.2.0 → 0.2.2
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 +26 -2
- package/dist/agent/plugin.js +2 -1
- package/dist/agent/tools/start.js +21 -4
- package/dist/agent/tools/watch-args.js +51 -3
- package/dist/agent/tools/watch.js +9 -11
- package/dist/connect.js +3 -2
- package/dist/core/config.js +5 -1
- package/dist/core/format.js +27 -2
- package/dist/tui/components/console.js +9 -0
- package/dist/tui/components/dock.js +113 -106
- package/dist/tui/dialogs.js +48 -1
- package/dist/tui/index.js +19 -2
- package/dist/tui/lib/view.js +17 -1
- package/dist/tui/state/store.js +28 -4
- package/package.json +4 -4
- package/types/agent/tools/watch-args.d.ts +13 -5
- package/types/connect.d.ts +1 -1
- package/types/core/config.d.ts +12 -1
- package/types/tui/dialogs.d.ts +5 -0
- package/types/tui/lib/view.d.ts +2 -0
- package/types/tui/state/store.d.ts +12 -1
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# @opencode-cockpit/shell
|
|
2
2
|
|
|
3
|
+
**Bay 01 of [Cockpit](https://github.com/Codestz/opencode-cockpit).**
|
|
4
|
+
[Docs →](https://codestz.github.io/opencode-cockpit/shell/overview/) ·
|
|
5
|
+
[Agent tools](https://codestz.github.io/opencode-cockpit/shell/tools/) ·
|
|
6
|
+
[Watching health](https://codestz.github.io/opencode-cockpit/shell/watching/) ·
|
|
7
|
+
[Panel and keys](https://codestz.github.io/opencode-cockpit/shell/interface/) ·
|
|
8
|
+
[Configuration](https://codestz.github.io/opencode-cockpit/configuration/)
|
|
9
|
+
|
|
3
10
|
[](https://www.npmjs.com/package/@opencode-cockpit/shell)
|
|
4
11
|
|
|
5
12
|
Background terminals for [OpenCode](https://opencode.ai) that the agent starts, waits on and
|
|
@@ -15,6 +22,10 @@ drives, and that you watch and control without leaving the chat. Part of
|
|
|
15
22
|
- **Shells outlive OpenCode.** They run in a small daemon, `cockpitd`, shared by every OpenCode
|
|
16
23
|
window and upgraded automatically when the plugin updates.
|
|
17
24
|
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
*Recorded from a real session, re-recorded on every release.*
|
|
28
|
+
|
|
18
29
|
## Install
|
|
19
30
|
|
|
20
31
|
```sh
|
|
@@ -74,12 +85,17 @@ A watch rule is three regexes, not a parser: `done` (a run ended), `fail` and `o
|
|
|
74
85
|
tsc, eslint, biome, prettier, mypy, ruff, vitest, jest, mocha, bun test, deno test, pytest, rspec,
|
|
75
86
|
phpunit, playwright, cypress, vite, next, nuxt, astro, angular, webpack/rspack, esbuild, tsup,
|
|
76
87
|
turbo, metro, storybook, cargo, go, dotnet, gradle, maven, docker compose and terraform. Anything
|
|
77
|
-
else takes its own patterns:
|
|
88
|
+
else takes its own patterns, at `shell_start` or later:
|
|
78
89
|
|
|
79
90
|
```
|
|
91
|
+
shell_start command="./deploy.sh" description="deploy" watch={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
|
|
80
92
|
shell_watch name="deploy" rule={ fail: "FAILED", ok: "SUCCEEDED", idleSeconds: 5 }
|
|
81
93
|
```
|
|
82
94
|
|
|
95
|
+
When no preset fits and no rule is given — `sleep 300`, a plain script, anything that prints nothing
|
|
96
|
+
recognizable — watching still works: the shell is watched for **dying** (preset `exit`), which is
|
|
97
|
+
crash detection with no patterns to write.
|
|
98
|
+
|
|
83
99
|
Things to ask:
|
|
84
100
|
|
|
85
101
|
- *"Start the dev server in a background shell and wait until it's ready."*
|
|
@@ -91,9 +107,12 @@ Things to ask:
|
|
|
91
107
|
| Key / command | Does |
|
|
92
108
|
|---|---|
|
|
93
109
|
| `ctrl+x o` · `/shells` | Toggle the shells panel under the chat |
|
|
110
|
+
| `s` (in the console) | This conversation only, or the whole project |
|
|
94
111
|
| `ctrl+x i` · `/shell` | Open the shell console |
|
|
95
112
|
| `/shell-new` | Start a shell yourself |
|
|
96
113
|
| `/shells-clear` | Remove finished shells |
|
|
114
|
+
| `/shells-stop` | Stop the shells in view — this conversation, or the project |
|
|
115
|
+
| `/shells-stop-all` | Stop every shell in the project, asking first about the ones you cannot see |
|
|
97
116
|
| `/cockpit-update` | Update the plugin when a newer release exists |
|
|
98
117
|
| `/shells-restart-daemon` | Restart `cockpitd` (asks first when shells are running) |
|
|
99
118
|
|
|
@@ -107,6 +126,10 @@ the program, `ctrl+]` to stop typing), `c` ctrl+c, `r` restart, `x` stop. Finish
|
|
|
107
126
|
again, `d` remove. Always: `tab` screen or log, `/` search the log (`backspace` clears the filter),
|
|
108
127
|
`?` details, `[` `]` switch shells, `D` clear finished, `esc` close.
|
|
109
128
|
|
|
129
|
+
**One conversation at a time.** The panel lists the shells of the session you are in, because a
|
|
130
|
+
project's shells mixed together is how it stops making sense. `s` in the console widens it to the
|
|
131
|
+
whole project and back, and `shell_list` takes `session: "this" | "others" | "any"`.
|
|
132
|
+
|
|
110
133
|
**Searching a long log.** In the console, `/` filters the scrollback to matching lines — keeping
|
|
111
134
|
their original line numbers and highlighting the match — so a 40k-line dev server is one query away
|
|
112
135
|
from the five lines you want. Filtering happens in the daemon, not the terminal.
|
|
@@ -147,11 +170,12 @@ working. (`XDG_CONFIG_HOME` is honoured for the global path.)
|
|
|
147
170
|
| `kinds` | Extra shell categories, or overrides, as name → regex matched against the command. Drives the badges in the sidebar, panel and `shell_list`, so you can group your own stack (`e2e`, `infra`, `worker`) instead of the built-ins. |
|
|
148
171
|
| `watch.presets` | Your own watch rules, or replacements for built-ins, keyed by name. A rule is `{ done?, fail?, ok?, idleSeconds?, ignoreCase? }` — patterns are regular expressions matched against each output line. The agent can then ask for `watch: "e2e"`. |
|
|
149
172
|
| `watch.auto` | Attach a matching preset to every new shell without being asked. **Off by default**: watching is useful, but silently attaching rules to commands you didn't opt in surprises people. Turn it on once you trust your presets. |
|
|
173
|
+
| `lifecycle` | When shells end by themselves. `onExit` (`stopMine` by default) stops this window's shells when it closes; `keep` leaves them for the next window. `orphanAfterMinutes` (60) stops a shell that no window has claimed for that long — the answer to shells running for a week. |
|
|
150
174
|
| `defaults` | Applied to every shell the agent starts unless the call says otherwise: `watch` (`true` for a matching preset, or a preset name), `logFile`, `idleTimeoutSeconds`, `timeoutSeconds`, `notifyOnExit`. |
|
|
151
175
|
| `notify` | What may interrupt the agent — `exit`, `watch`, and `tailLines` (output lines included in an exit message). |
|
|
152
176
|
| `guidance` | The system-prompt paragraph that teaches the agent when to use shells. `false` saves ~120 tokens per request, at the cost of a model that uses shells less well. |
|
|
153
177
|
| `listRunningShells` | How many running shells are listed in the system prompt each turn (~20 tokens each). `0` disables it; the agent can still call `shell_list`. |
|
|
154
|
-
| `ui` | Interface only: `dockHeight`, `dockOpen
|
|
178
|
+
| `ui` | Interface only: `dockHeight`, `dockOpen` (set it and the panel always starts that way; leave it out and it starts as you last left it), `sidebarRows`, `historyMinutes`, `colors`, `defaultView` (`"screen"` or `"log"`), `keybinds`, `updateCheck`. |
|
|
155
179
|
|
|
156
180
|
The same settings can go on the plugin entry instead, which is handy for one-offs and for machines
|
|
157
181
|
where you'd rather keep everything in `tui.json`/`opencode.json`:
|
package/dist/agent/plugin.js
CHANGED
|
@@ -44,8 +44,9 @@ async function shellHooks({
|
|
|
44
44
|
directory
|
|
45
45
|
}, options) {
|
|
46
46
|
const config = loadConfig(directory, options);
|
|
47
|
-
|
|
47
|
+
// Identifies this OpenCode window to the daemon, so shells can end with it.
|
|
48
48
|
const instance = crypto.randomUUID();
|
|
49
|
+
const cockpit = createClient("opencode-cockpit/server", instance);
|
|
49
50
|
const quiet = new Set();
|
|
50
51
|
const userShell = process.env.SHELL && /(bash|zsh|fish|sh)$/.test(process.env.SHELL) ? process.env.SHELL : "/bin/bash";
|
|
51
52
|
const env = () => {
|
|
@@ -48,7 +48,13 @@ export function shellStart(kit) {
|
|
|
48
48
|
timeoutSeconds: z.number().positive().max(3600).default(120)
|
|
49
49
|
}).optional().describe("Block until ready. Same conditions as shell_wait."),
|
|
50
50
|
notifyOnExit: z.boolean().default(true).describe("Message you when the process exits"),
|
|
51
|
-
watch: z.union([z.boolean(), z.string()
|
|
51
|
+
watch: z.union([z.boolean(), z.string(), z.object({
|
|
52
|
+
done: z.string().optional().describe("A run finished, e.g. 'Found \\d+ errors'"),
|
|
53
|
+
fail: z.string().optional(),
|
|
54
|
+
ok: z.string().optional(),
|
|
55
|
+
ignoreCase: z.boolean().optional(),
|
|
56
|
+
idleSeconds: z.number().positive().optional()
|
|
57
|
+
})]).optional().describe('Watch this shell\'s health and message you only when it changes. true or "auto" picks a preset from the command (tsc, vitest, cargo…) and falls back to reporting the process dying; a name picks that preset; an object is your own rule, e.g. { done: "\\d+ (passed|failed)", fail: "\\d+ failed" }.'),
|
|
52
58
|
timeoutSeconds: z.number().int().positive().optional().describe("Stop the process after this long, busy or not. Good for bounded jobs and probes."),
|
|
53
59
|
idleTimeoutSeconds: z.number().int().positive().optional().describe("Stop the process after this much silence. Never use it for dev servers, which are idle when healthy."),
|
|
54
60
|
logFile: z.boolean().default(false).describe("Also write the clean log to a file, so old lines survive the in-memory buffer")
|
|
@@ -60,6 +66,9 @@ export function shellStart(kit) {
|
|
|
60
66
|
const logFile = args.logFile ?? defaults.logFile ?? false;
|
|
61
67
|
const notifyOnExit = args.notifyOnExit ?? defaults.notifyOnExit ?? true;
|
|
62
68
|
const watch = args.watch ?? defaults.watch ?? (config.watch?.auto ? "auto" : undefined);
|
|
69
|
+
// Nothing should outlive the window that asked for it by more than the user allows.
|
|
70
|
+
const lifecycle = config.lifecycle ?? {};
|
|
71
|
+
const orphanMinutes = lifecycle.orphanAfterMinutes ?? 60;
|
|
63
72
|
const shell = deps.shellCommand(args.command);
|
|
64
73
|
const info = await client.call("shell.start", {
|
|
65
74
|
command: shell.command,
|
|
@@ -78,6 +87,8 @@ export function shellStart(kit) {
|
|
|
78
87
|
timeoutMs: seconds(args.timeoutSeconds ?? defaults.timeoutSeconds),
|
|
79
88
|
idleTimeoutMs: seconds(args.idleTimeoutSeconds ?? defaults.idleTimeoutSeconds),
|
|
80
89
|
logFile: logFile === true,
|
|
90
|
+
stopOnExit: (lifecycle.onExit ?? "stopMine") === "stopMine",
|
|
91
|
+
orphanAfterMs: orphanMinutes > 0 ? orphanMinutes * 60_000 : undefined,
|
|
81
92
|
reuse: true
|
|
82
93
|
});
|
|
83
94
|
if (notifyOnExit === false) deps.quiet.add(info.id);else deps.quiet.delete(info.id);
|
|
@@ -91,11 +102,10 @@ export function shellStart(kit) {
|
|
|
91
102
|
if (info.status === "failed") return `${header(info)}\n${describeStatus(info)}\n</shell>`;
|
|
92
103
|
const lines = [info.run > 1 ? `Restarted ${info.id} (run ${info.run}): same command as an earlier finished shell in this session. Earlier output is above line ${info.lines.last}.` : `Started ${info.id}: ${args.command}`];
|
|
93
104
|
if (watch) {
|
|
94
|
-
const preset = typeof watch === "string" ? watch : "auto";
|
|
95
105
|
await client.call("shell.watch", {
|
|
96
106
|
id: info.id,
|
|
97
|
-
...watchArgs(
|
|
98
|
-
}).then(watched => lines.push(
|
|
107
|
+
...watchArgs(watch, config)
|
|
108
|
+
}).then(watched => lines.push(describeWatch(watched))).catch(err => lines.push(`could not watch: ${err instanceof Error ? err.message : String(err)}`));
|
|
99
109
|
}
|
|
100
110
|
if (args.waitFor) {
|
|
101
111
|
const {
|
|
@@ -134,6 +144,13 @@ export function shellStart(kit) {
|
|
|
134
144
|
});
|
|
135
145
|
}
|
|
136
146
|
|
|
147
|
+
/** What was actually attached: a preset, your own rule, or plain crash reporting. */
|
|
148
|
+
function describeWatch(info) {
|
|
149
|
+
const preset = info.watch?.preset;
|
|
150
|
+
if (preset === "exit") return "watching: no health patterns fit this command, so you will be messaged if it dies";
|
|
151
|
+
return `watching health (${preset ?? "custom rule"}); changes will be messaged to you`;
|
|
152
|
+
}
|
|
153
|
+
|
|
137
154
|
/** Milliseconds from an option in seconds, or undefined when unset. */
|
|
138
155
|
function seconds(value) {
|
|
139
156
|
return value ? Math.round(value * 1000) : undefined;
|
|
@@ -1,12 +1,60 @@
|
|
|
1
|
+
/** A backslash that JSON does not allow — `\d`, `\s`, `\(` — i.e. someone wrote a regex in here. */
|
|
2
|
+
const LONE_ESCAPE = /\\(?!["\\/bfnrtu])/g;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
5
|
+
* Rules are regexes, and a regex written inside JSON text is usually under-escaped (`"\d+ passed"`
|
|
6
|
+
* is not valid JSON). Parse it as written first, then again with those backslashes escaped, so a
|
|
7
|
+
* model's JSON does not have to be perfect for its patterns to survive.
|
|
4
8
|
*/
|
|
5
|
-
|
|
9
|
+
function parseLoosely(text) {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(text);
|
|
12
|
+
} catch {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(text.replace(LONE_ESCAPE, "\\\\"));
|
|
15
|
+
} catch {
|
|
16
|
+
return undefined; // not JSON at all: treated as a preset name, which says so downstream
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The keys a rule may carry; anything else in an object is not a rule. */
|
|
22
|
+
const RULE_KEYS = new Set(["done", "fail", "ok", "ignoreCase", "idleSeconds"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turns whatever the model passed for `watch` into what `shell.watch` needs.
|
|
26
|
+
*
|
|
27
|
+
* `true`/`"auto"` asks the daemon to pick a preset, a name picks that preset — and a preset defined
|
|
28
|
+
* in config travels as an explicit rule, so users can add tools without touching the daemon. Rule
|
|
29
|
+
* objects arrive as JSON *strings* often enough (models write one, and tool args are not always
|
|
30
|
+
* parsed the way the schema says) that a string which looks like an object is parsed rather than
|
|
31
|
+
* handed on as a preset name nobody has.
|
|
32
|
+
*/
|
|
33
|
+
export function watchArgs(watch, config) {
|
|
34
|
+
const rule = asWatchRule(watch);
|
|
35
|
+
if (rule) return {
|
|
36
|
+
rule
|
|
37
|
+
};
|
|
38
|
+
const preset = watch === true || watch == null ? "auto" : String(watch);
|
|
6
39
|
const custom = config.watch?.presets?.[preset];
|
|
7
40
|
return custom ? {
|
|
8
41
|
rule: custom
|
|
9
42
|
} : {
|
|
10
43
|
preset
|
|
11
44
|
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A rule from an object, or from the JSON string a model sometimes writes instead. */
|
|
48
|
+
export function asWatchRule(value) {
|
|
49
|
+
let candidate = value;
|
|
50
|
+
if (typeof candidate === "string") {
|
|
51
|
+
const text = candidate.trim();
|
|
52
|
+
if (!text.startsWith("{")) return undefined;
|
|
53
|
+
const parsed = parseLoosely(text);
|
|
54
|
+
if (parsed === undefined) return undefined;
|
|
55
|
+
candidate = parsed;
|
|
56
|
+
}
|
|
57
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return undefined;
|
|
58
|
+
const entries = Object.entries(candidate).filter(([k, v]) => RULE_KEYS.has(k) && v != null);
|
|
59
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
12
60
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { tool } from "@opencode-ai/plugin";
|
|
2
|
-
import { watchArgs } from "./watch-args.js";
|
|
2
|
+
import { asWatchRule, watchArgs } from "./watch-args.js";
|
|
3
3
|
const WATCH = `Keep an eye on a long-running shell and be told only when its health changes.
|
|
4
4
|
|
|
5
5
|
For processes that never exit (tsc --watch, vitest --watch, dev servers) this replaces re-reading
|
|
6
6
|
the log: you get one message when it breaks, and one when it is fixed, and nothing while it repeats
|
|
7
7
|
the same result.
|
|
8
8
|
|
|
9
|
-
- preset: a named rule ("auto" picks one from the command
|
|
10
|
-
|
|
9
|
+
- preset: a named rule ("auto" picks one from the command, and falls back to reporting the process
|
|
10
|
+
dying when no patterns fit). Presets exist for tsc, vitest, jest, eslint, biome, cargo, go,
|
|
11
|
+
gradle, pytest, vite, next, docker-compose and more; "exit" watches only for the process dying,
|
|
12
|
+
which is how you get crash detection for a command that prints nothing useful.
|
|
11
13
|
- rule: your own patterns when no preset fits: done (a run ended), fail, ok, idleSeconds.
|
|
12
14
|
- off: stop watching.
|
|
13
15
|
|
|
@@ -56,17 +58,13 @@ export function shellWatch(kit) {
|
|
|
56
58
|
return `${note}stopped watching ${stopped.id}`;
|
|
57
59
|
}
|
|
58
60
|
// A preset defined in config travels as an explicit rule; the daemon knows only the built-ins.
|
|
61
|
+
// Both arguments absorb a rule written as JSON, rather than failing on a preset name nobody has.
|
|
59
62
|
const chosen = args.preset ? watchArgs(args.preset, config) : {};
|
|
63
|
+
const rule = asWatchRule(args.rule) ?? chosen.rule;
|
|
60
64
|
const info = await client.call("shell.watch", {
|
|
61
65
|
id,
|
|
62
|
-
preset: chosen.preset,
|
|
63
|
-
rule
|
|
64
|
-
done: args.rule.done ?? undefined,
|
|
65
|
-
fail: args.rule.fail ?? undefined,
|
|
66
|
-
ok: args.rule.ok ?? undefined,
|
|
67
|
-
ignoreCase: args.rule.ignoreCase ?? undefined,
|
|
68
|
-
idleSeconds: args.rule.idleSeconds ?? undefined
|
|
69
|
-
} : chosen.rule
|
|
66
|
+
preset: rule ? undefined : chosen.preset,
|
|
67
|
+
rule
|
|
70
68
|
});
|
|
71
69
|
return `${note}watching ${info.id} (${info.watch?.preset ?? "custom rule"}). You will be messaged when its health changes; no need to poll.`;
|
|
72
70
|
}
|
package/dist/connect.js
CHANGED
|
@@ -14,13 +14,14 @@ export function daemonEntry() {
|
|
|
14
14
|
* BUN_BE_BUN=1 so it runs on OpenCode's embedded Bun (ADR 0001). The expected build lets the
|
|
15
15
|
* client replace a daemon left running from older plugin code.
|
|
16
16
|
*/
|
|
17
|
-
export function createClient(name) {
|
|
17
|
+
export function createClient(name, instance) {
|
|
18
18
|
const entry = daemonEntry();
|
|
19
19
|
return new CockpitClient({
|
|
20
20
|
client: {
|
|
21
21
|
name,
|
|
22
22
|
version: pkg.version,
|
|
23
|
-
pid: process.pid
|
|
23
|
+
pid: process.pid,
|
|
24
|
+
instance
|
|
24
25
|
},
|
|
25
26
|
spawn: {
|
|
26
27
|
entry,
|
package/dist/core/config.js
CHANGED
|
@@ -53,6 +53,10 @@ export function mergeConfig(base, over) {
|
|
|
53
53
|
...base.defaults,
|
|
54
54
|
...over.defaults
|
|
55
55
|
},
|
|
56
|
+
lifecycle: {
|
|
57
|
+
...base.lifecycle,
|
|
58
|
+
...over.lifecycle
|
|
59
|
+
},
|
|
56
60
|
notify: {
|
|
57
61
|
...base.notify,
|
|
58
62
|
...over.notify
|
|
@@ -76,7 +80,7 @@ function asConfig(input) {
|
|
|
76
80
|
if (!input || typeof input !== "object") return {};
|
|
77
81
|
const raw = input;
|
|
78
82
|
const config = {};
|
|
79
|
-
for (const key of ["watch", "kinds", "defaults", "notify", "ui"]) {
|
|
83
|
+
for (const key of ["watch", "kinds", "defaults", "lifecycle", "notify", "ui"]) {
|
|
80
84
|
const value = raw[key];
|
|
81
85
|
if (value && typeof value === "object") Object.assign(config, {
|
|
82
86
|
[key]: value
|
package/dist/core/format.js
CHANGED
|
@@ -16,17 +16,42 @@ export function formatLines(lines) {
|
|
|
16
16
|
return out.join("\n");
|
|
17
17
|
}
|
|
18
18
|
export function describeStatus(info) {
|
|
19
|
+
const ran = () => duration((info.endedAt ?? Date.now()) - info.startedAt);
|
|
19
20
|
switch (info.status) {
|
|
20
21
|
case "running":
|
|
21
22
|
return `running (pid ${info.pid}, up ${duration(Date.now() - info.startedAt)})`;
|
|
22
23
|
case "exited":
|
|
23
|
-
return `exited with code ${info.exitCode ?? "?"} after ${
|
|
24
|
+
return info.exitCode === 0 ? `exited cleanly after ${ran()}` : `crashed with exit code ${info.exitCode ?? "?"} after ${ran()}`;
|
|
24
25
|
case "killed":
|
|
25
|
-
return
|
|
26
|
+
return `${stopPhrase(info)} after ${ran()}`;
|
|
26
27
|
case "failed":
|
|
27
28
|
return `failed to start: ${info.error ?? "unknown error"}`;
|
|
28
29
|
}
|
|
29
30
|
}
|
|
31
|
+
|
|
32
|
+
/** Who ended a shell, and why — "killed by SIGTERM" alone never said which of us did it. */
|
|
33
|
+
function stopPhrase(info) {
|
|
34
|
+
switch (info.stopReason) {
|
|
35
|
+
case "timeout":
|
|
36
|
+
return "stopped: hit its time limit";
|
|
37
|
+
case "idle":
|
|
38
|
+
return "stopped: no output for its idle limit";
|
|
39
|
+
case "shutdown":
|
|
40
|
+
return "stopped because the shell daemon shut down";
|
|
41
|
+
case "request":
|
|
42
|
+
return `stopped by ${actor(info.stoppedBy)}`;
|
|
43
|
+
default:
|
|
44
|
+
return info.exitCode != null && info.exitCode !== 0 ? `crashed (exit code ${info.exitCode})` : `killed${info.signal ? ` by ${info.signal}` : ""} from outside`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Client names are wire identifiers; say them the way a person would. */
|
|
49
|
+
function actor(client) {
|
|
50
|
+
if (!client) return "a request";
|
|
51
|
+
if (client.includes("tui")) return "you, from the shells panel";
|
|
52
|
+
if (client.includes("server")) return "the agent";
|
|
53
|
+
return client;
|
|
54
|
+
}
|
|
30
55
|
export function header(info) {
|
|
31
56
|
const run = info.run > 1 ? ` run=${info.run}` : "";
|
|
32
57
|
return `<shell id="${info.id}" title="${info.title.replaceAll('"', "'")}" status="${info.status}"${run}>`;
|
|
@@ -271,6 +271,10 @@ export function Console(props) {
|
|
|
271
271
|
name: "cockpit.console.top",
|
|
272
272
|
title: "Scroll to top",
|
|
273
273
|
run: () => scroll?.scrollTo(0)
|
|
274
|
+
}, {
|
|
275
|
+
name: "cockpit.console.scope",
|
|
276
|
+
title: "This session / whole project",
|
|
277
|
+
run: () => props.store.toggleScope()
|
|
274
278
|
}, {
|
|
275
279
|
name: "cockpit.console.close",
|
|
276
280
|
title: "Close console",
|
|
@@ -328,6 +332,10 @@ export function Console(props) {
|
|
|
328
332
|
key: "n",
|
|
329
333
|
cmd: "cockpit.console.new",
|
|
330
334
|
desc: "New"
|
|
335
|
+
}, {
|
|
336
|
+
key: "s",
|
|
337
|
+
cmd: "cockpit.console.scope",
|
|
338
|
+
desc: "Scope"
|
|
331
339
|
}, {
|
|
332
340
|
key: "j,down",
|
|
333
341
|
cmd: "cockpit.console.down",
|
|
@@ -380,6 +388,7 @@ export function Console(props) {
|
|
|
380
388
|
if (view() !== "details") keys.push(label("/ search log", "/"));
|
|
381
389
|
keys.push(label("? details", "?"));
|
|
382
390
|
if (props.store.shells().length > 1) keys.push(label("[ ] switch", "[ ]"));
|
|
391
|
+
keys.push(props.store.scope() === "session" ? label("s whole project", "s project") : label("s this session", "s session"));
|
|
383
392
|
if (finished() > 0) keys.push(label("D clear done", "D"));
|
|
384
393
|
keys.push(label("n new", "n"), "esc");
|
|
385
394
|
return keys.join(" · ");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { effect as _$effect } from "@opentui/solid";
|
|
2
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
2
3
|
import { insert as _$insert } from "@opentui/solid";
|
|
3
4
|
import { memo as _$memo } from "@opentui/solid";
|
|
4
|
-
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
5
5
|
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
6
6
|
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
7
7
|
import { setProp as _$setProp } from "@opentui/solid";
|
|
@@ -33,15 +33,17 @@ export function Dock(props) {
|
|
|
33
33
|
_el$2 = _$createElement("box"),
|
|
34
34
|
_el$3 = _$createElement("text"),
|
|
35
35
|
_el$4 = _$createElement("b"),
|
|
36
|
-
_el$
|
|
37
|
-
_el$8 = _$createElement("
|
|
36
|
+
_el$6 = _$createElement("text"),
|
|
37
|
+
_el$8 = _$createElement("box"),
|
|
38
|
+
_el$9 = _$createElement("text");
|
|
38
39
|
_$insertNode(_el$, _el$2);
|
|
39
40
|
_$setProp(_el$, "flexShrink", 0);
|
|
40
41
|
_$setProp(_el$, "flexDirection", "column");
|
|
41
42
|
_$setProp(_el$, "border", ["top"]);
|
|
42
43
|
_$insertNode(_el$2, _el$3);
|
|
43
|
-
_$insertNode(_el$2, _el$
|
|
44
|
+
_$insertNode(_el$2, _el$6);
|
|
44
45
|
_$insertNode(_el$2, _el$8);
|
|
46
|
+
_$insertNode(_el$2, _el$9);
|
|
45
47
|
_$setProp(_el$2, "flexDirection", "row");
|
|
46
48
|
_$setProp(_el$2, "gap", 1);
|
|
47
49
|
_$setProp(_el$2, "paddingLeft", 1);
|
|
@@ -52,17 +54,19 @@ export function Dock(props) {
|
|
|
52
54
|
_$insertNode(_el$3, _el$4);
|
|
53
55
|
_$setProp(_el$3, "flexShrink", 0);
|
|
54
56
|
_$insertNode(_el$4, _$createTextNode(`Shells`));
|
|
57
|
+
_$setProp(_el$6, "flexShrink", 0);
|
|
58
|
+
_$insert(_el$6, () => props.store.scope() === "session" ? "this session" : "project");
|
|
55
59
|
_$insert(_el$2, _$createComponent(Show, {
|
|
56
60
|
get when() {
|
|
57
61
|
return props.store.shells().length > 0;
|
|
58
62
|
},
|
|
59
63
|
get fallback() {
|
|
60
64
|
return (() => {
|
|
61
|
-
var _el$
|
|
62
|
-
_$insertNode(_el$
|
|
63
|
-
_$setProp(_el$
|
|
64
|
-
_$effect(_$p => _$setProp(_el$
|
|
65
|
-
return _el$
|
|
65
|
+
var _el$0 = _$createElement("text");
|
|
66
|
+
_$insertNode(_el$0, _$createTextNode(`none yet · the agent starts them, or /shell-new`));
|
|
67
|
+
_$setProp(_el$0, "wrapMode", "none");
|
|
68
|
+
_$effect(_$p => _$setProp(_el$0, "fg", theme().textMuted, _$p));
|
|
69
|
+
return _el$0;
|
|
66
70
|
})();
|
|
67
71
|
},
|
|
68
72
|
get children() {
|
|
@@ -73,15 +77,15 @@ export function Dock(props) {
|
|
|
73
77
|
children: shell => {
|
|
74
78
|
const active = () => props.store.selected()?.id === shell.id;
|
|
75
79
|
return (() => {
|
|
76
|
-
var _el$
|
|
77
|
-
_el$
|
|
78
|
-
_el$
|
|
79
|
-
_$insertNode(_el$
|
|
80
|
-
_$setProp(_el$
|
|
81
|
-
_$setProp(_el$
|
|
82
|
-
_$setProp(_el$
|
|
83
|
-
_$setProp(_el$
|
|
84
|
-
_$insert(_el$
|
|
80
|
+
var _el$10 = _$createElement("box"),
|
|
81
|
+
_el$11 = _$createElement("text"),
|
|
82
|
+
_el$12 = _$createTextNode(` `);
|
|
83
|
+
_$insertNode(_el$10, _el$11);
|
|
84
|
+
_$setProp(_el$10, "flexDirection", "row");
|
|
85
|
+
_$setProp(_el$10, "flexShrink", 0);
|
|
86
|
+
_$setProp(_el$10, "paddingRight", 1);
|
|
87
|
+
_$setProp(_el$10, "onMouseUp", () => active() ? props.onOpenConsole(shell.id) : props.store.select(shell.id));
|
|
88
|
+
_$insert(_el$10, _$createComponent(Badge, {
|
|
85
89
|
get api() {
|
|
86
90
|
return props.api;
|
|
87
91
|
},
|
|
@@ -89,28 +93,28 @@ export function Dock(props) {
|
|
|
89
93
|
get frame() {
|
|
90
94
|
return props.store.frame();
|
|
91
95
|
}
|
|
92
|
-
}), _el$
|
|
93
|
-
_$insertNode(_el$
|
|
94
|
-
_$setProp(_el$
|
|
95
|
-
_$insert(_el$
|
|
96
|
+
}), _el$11);
|
|
97
|
+
_$insertNode(_el$11, _el$12);
|
|
98
|
+
_$setProp(_el$11, "wrapMode", "none");
|
|
99
|
+
_$insert(_el$11, (() => {
|
|
96
100
|
var _c$2 = _$memo(() => !!active());
|
|
97
101
|
return () => _c$2() ? (() => {
|
|
98
|
-
var _el$
|
|
99
|
-
_$insert(_el$
|
|
100
|
-
return _el$
|
|
102
|
+
var _el$13 = _$createElement("b");
|
|
103
|
+
_$insert(_el$13, () => truncate(shell.title, 22));
|
|
104
|
+
return _el$13;
|
|
101
105
|
})() : truncate(shell.title, 22);
|
|
102
106
|
})(), null);
|
|
103
107
|
_$effect(_p$ => {
|
|
104
|
-
var _v$
|
|
105
|
-
_v$
|
|
106
|
-
_v$
|
|
107
|
-
_v$
|
|
108
|
+
var _v$7 = active() ? theme().backgroundElement : theme().backgroundPanel,
|
|
109
|
+
_v$8 = active() ? theme().text : theme().textMuted;
|
|
110
|
+
_v$7 !== _p$.e && (_p$.e = _$setProp(_el$10, "backgroundColor", _v$7, _p$.e));
|
|
111
|
+
_v$8 !== _p$.t && (_p$.t = _$setProp(_el$11, "fg", _v$8, _p$.t));
|
|
108
112
|
return _p$;
|
|
109
113
|
}, {
|
|
110
114
|
e: undefined,
|
|
111
115
|
t: undefined
|
|
112
116
|
});
|
|
113
|
-
return _el$
|
|
117
|
+
return _el$10;
|
|
114
118
|
})();
|
|
115
119
|
}
|
|
116
120
|
}), _$createComponent(Show, {
|
|
@@ -118,132 +122,132 @@ export function Dock(props) {
|
|
|
118
122
|
return overflow() > 0 || props.store.showAll();
|
|
119
123
|
},
|
|
120
124
|
get children() {
|
|
121
|
-
var _el$
|
|
122
|
-
_$setProp(_el$
|
|
123
|
-
_$setProp(_el$
|
|
124
|
-
_$setProp(_el$
|
|
125
|
-
_$insert(_el$
|
|
125
|
+
var _el$7 = _$createElement("text");
|
|
126
|
+
_$setProp(_el$7, "wrapMode", "none");
|
|
127
|
+
_$setProp(_el$7, "flexShrink", 0);
|
|
128
|
+
_$setProp(_el$7, "onMouseUp", () => props.store.toggleAll());
|
|
129
|
+
_$insert(_el$7, (() => {
|
|
126
130
|
var _c$ = _$memo(() => !!props.store.showAll());
|
|
127
131
|
return () => _c$() ? "▾ fewer" : `▸ ${overflow()} more`;
|
|
128
132
|
})());
|
|
129
|
-
_$effect(_$p => _$setProp(_el$
|
|
130
|
-
return _el$
|
|
133
|
+
_$effect(_$p => _$setProp(_el$7, "fg", theme().textMuted, _$p));
|
|
134
|
+
return _el$7;
|
|
131
135
|
}
|
|
132
136
|
})];
|
|
133
137
|
}
|
|
134
|
-
}), _el$
|
|
135
|
-
_$setProp(_el$
|
|
136
|
-
_$setProp(_el$
|
|
137
|
-
_$setProp(_el$
|
|
138
|
-
_$insert(_el$
|
|
138
|
+
}), _el$8);
|
|
139
|
+
_$setProp(_el$8, "flexGrow", 1);
|
|
140
|
+
_$setProp(_el$9, "wrapMode", "none");
|
|
141
|
+
_$setProp(_el$9, "flexShrink", 0);
|
|
142
|
+
_$insert(_el$9, () => props.hint());
|
|
139
143
|
_$insert(_el$, _$createComponent(Show, {
|
|
140
144
|
get when() {
|
|
141
145
|
return props.store.selected();
|
|
142
146
|
},
|
|
143
147
|
children: shell => [(() => {
|
|
144
|
-
var _el$
|
|
145
|
-
_$setProp(_el$
|
|
146
|
-
_$setProp(_el$
|
|
147
|
-
_$setProp(_el$
|
|
148
|
-
_$setProp(_el$
|
|
149
|
-
_$setProp(_el$
|
|
150
|
-
_$setProp(_el$
|
|
151
|
-
_$insert(_el$
|
|
148
|
+
var _el$14 = _$createElement("box");
|
|
149
|
+
_$setProp(_el$14, "flexGrow", 1);
|
|
150
|
+
_$setProp(_el$14, "paddingLeft", 2);
|
|
151
|
+
_$setProp(_el$14, "paddingRight", 1);
|
|
152
|
+
_$setProp(_el$14, "minHeight", 0);
|
|
153
|
+
_$setProp(_el$14, "overflow", "hidden");
|
|
154
|
+
_$setProp(_el$14, "onMouseUp", () => props.onOpenConsole(shell().id));
|
|
155
|
+
_$insert(_el$14, _$createComponent(Show, {
|
|
152
156
|
get when() {
|
|
153
157
|
return bodyRuns().length > 0;
|
|
154
158
|
},
|
|
155
159
|
get fallback() {
|
|
156
160
|
return (() => {
|
|
157
|
-
var _el$
|
|
158
|
-
_$setProp(_el$
|
|
159
|
-
_$insert(_el$
|
|
160
|
-
_$effect(_$p => _$setProp(_el$
|
|
161
|
-
return _el$
|
|
161
|
+
var _el$20 = _$createElement("text");
|
|
162
|
+
_$setProp(_el$20, "wrapMode", "none");
|
|
163
|
+
_$insert(_el$20, () => body() || " ");
|
|
164
|
+
_$effect(_$p => _$setProp(_el$20, "fg", theme().text, _$p));
|
|
165
|
+
return _el$20;
|
|
162
166
|
})();
|
|
163
167
|
},
|
|
164
168
|
get children() {
|
|
165
|
-
var _el$
|
|
166
|
-
_$setProp(_el$
|
|
167
|
-
_$insert(_el$
|
|
169
|
+
var _el$15 = _$createElement("box");
|
|
170
|
+
_$setProp(_el$15, "flexDirection", "column");
|
|
171
|
+
_$insert(_el$15, _$createComponent(For, {
|
|
168
172
|
get each() {
|
|
169
173
|
return bodyRuns();
|
|
170
174
|
},
|
|
171
175
|
children: row => (() => {
|
|
172
|
-
var _el$
|
|
173
|
-
_el$
|
|
174
|
-
_$insertNode(_el$
|
|
175
|
-
_$setProp(_el$
|
|
176
|
-
_$insert(_el$
|
|
176
|
+
var _el$21 = _$createElement("text"),
|
|
177
|
+
_el$22 = _$createTextNode(` `);
|
|
178
|
+
_$insertNode(_el$21, _el$22);
|
|
179
|
+
_$setProp(_el$21, "wrapMode", "none");
|
|
180
|
+
_$insert(_el$21, _$createComponent(For, {
|
|
177
181
|
each: row,
|
|
178
182
|
children: run => (() => {
|
|
179
|
-
var _el$
|
|
180
|
-
_$insert(_el$
|
|
181
|
-
_$effect(_$p => _$setProp(_el$
|
|
183
|
+
var _el$23 = _$createElement("span");
|
|
184
|
+
_$insert(_el$23, () => run.text);
|
|
185
|
+
_$effect(_$p => _$setProp(_el$23, "style", {
|
|
182
186
|
fg: run.fg,
|
|
183
187
|
bg: run.bg,
|
|
184
188
|
bold: run.bold,
|
|
185
189
|
italic: run.italic
|
|
186
190
|
}, _$p));
|
|
187
|
-
return _el$
|
|
191
|
+
return _el$23;
|
|
188
192
|
})()
|
|
189
|
-
}), _el$
|
|
190
|
-
_$effect(_$p => _$setProp(_el$
|
|
191
|
-
return _el$
|
|
193
|
+
}), _el$22);
|
|
194
|
+
_$effect(_$p => _$setProp(_el$21, "fg", theme().text, _$p));
|
|
195
|
+
return _el$21;
|
|
192
196
|
})()
|
|
193
197
|
}));
|
|
194
|
-
return _el$
|
|
198
|
+
return _el$15;
|
|
195
199
|
}
|
|
196
200
|
}));
|
|
197
|
-
return _el$
|
|
201
|
+
return _el$14;
|
|
198
202
|
})(), (() => {
|
|
199
|
-
var _el$
|
|
200
|
-
_el$
|
|
201
|
-
_$insertNode(_el$
|
|
202
|
-
_$setProp(_el$
|
|
203
|
-
_$setProp(_el$
|
|
204
|
-
_$setProp(_el$
|
|
205
|
-
_$setProp(_el$15, "flexShrink", 0);
|
|
206
|
-
_$setProp(_el$15, "height", 1);
|
|
207
|
-
_$setProp(_el$15, "overflow", "hidden");
|
|
208
|
-
_$setProp(_el$16, "wrapMode", "none");
|
|
203
|
+
var _el$16 = _$createElement("box"),
|
|
204
|
+
_el$17 = _$createElement("text");
|
|
205
|
+
_$insertNode(_el$16, _el$17);
|
|
206
|
+
_$setProp(_el$16, "flexDirection", "row");
|
|
207
|
+
_$setProp(_el$16, "gap", 1);
|
|
208
|
+
_$setProp(_el$16, "paddingLeft", 2);
|
|
209
209
|
_$setProp(_el$16, "flexShrink", 0);
|
|
210
|
-
_$
|
|
211
|
-
_$
|
|
210
|
+
_$setProp(_el$16, "height", 1);
|
|
211
|
+
_$setProp(_el$16, "overflow", "hidden");
|
|
212
|
+
_$setProp(_el$17, "wrapMode", "none");
|
|
213
|
+
_$setProp(_el$17, "flexShrink", 0);
|
|
214
|
+
_$insert(_el$17, () => statusDetail(shell(), props.store.now()));
|
|
215
|
+
_$insert(_el$16, _$createComponent(Show, {
|
|
212
216
|
get when() {
|
|
213
217
|
return watchLabel(shell());
|
|
214
218
|
},
|
|
215
219
|
get children() {
|
|
216
|
-
var _el$
|
|
217
|
-
_$setProp(_el$
|
|
218
|
-
_$setProp(_el$
|
|
219
|
-
_$insert(_el$
|
|
220
|
-
_$effect(_$p => _$setProp(_el$
|
|
221
|
-
return _el$
|
|
220
|
+
var _el$18 = _$createElement("text");
|
|
221
|
+
_$setProp(_el$18, "wrapMode", "none");
|
|
222
|
+
_$setProp(_el$18, "flexShrink", 0);
|
|
223
|
+
_$insert(_el$18, () => watchLabel(shell()));
|
|
224
|
+
_$effect(_$p => _$setProp(_el$18, "fg", watchColor(theme(), shell()), _$p));
|
|
225
|
+
return _el$18;
|
|
222
226
|
}
|
|
223
227
|
}), null);
|
|
224
|
-
_$insert(_el$
|
|
228
|
+
_$insert(_el$16, _$createComponent(Show, {
|
|
225
229
|
get when() {
|
|
226
230
|
return _$memo(() => kindOf(shell()) === "fail")() && shell().summary;
|
|
227
231
|
},
|
|
228
232
|
get fallback() {
|
|
229
233
|
return (() => {
|
|
230
|
-
var _el$
|
|
231
|
-
_$setProp(_el$
|
|
232
|
-
_$insert(_el$
|
|
233
|
-
_$effect(_$p => _$setProp(_el$
|
|
234
|
-
return _el$
|
|
234
|
+
var _el$24 = _$createElement("text");
|
|
235
|
+
_$setProp(_el$24, "wrapMode", "none");
|
|
236
|
+
_$insert(_el$24, () => truncate(`$ ${displayCommand(shell())}`, Math.max(20, dims().width - 40)));
|
|
237
|
+
_$effect(_$p => _$setProp(_el$24, "fg", theme().textMuted, _$p));
|
|
238
|
+
return _el$24;
|
|
235
239
|
})();
|
|
236
240
|
},
|
|
237
241
|
get children() {
|
|
238
|
-
var _el$
|
|
239
|
-
_$setProp(_el$
|
|
240
|
-
_$insert(_el$
|
|
241
|
-
_$effect(_$p => _$setProp(_el$
|
|
242
|
-
return _el$
|
|
242
|
+
var _el$19 = _$createElement("text");
|
|
243
|
+
_$setProp(_el$19, "wrapMode", "none");
|
|
244
|
+
_$insert(_el$19, () => truncate(`${shell().summary}`, Math.max(20, dims().width - 40)));
|
|
245
|
+
_$effect(_$p => _$setProp(_el$19, "fg", theme().error, _$p));
|
|
246
|
+
return _el$19;
|
|
243
247
|
}
|
|
244
248
|
}), null);
|
|
245
|
-
_$effect(_$p => _$setProp(_el$
|
|
246
|
-
return _el$
|
|
249
|
+
_$effect(_$p => _$setProp(_el$17, "fg", kindColor(theme(), kindOf(shell())), _$p));
|
|
250
|
+
return _el$16;
|
|
247
251
|
})()]
|
|
248
252
|
}), null);
|
|
249
253
|
_$effect(_p$ => {
|
|
@@ -251,19 +255,22 @@ export function Dock(props) {
|
|
|
251
255
|
_v$2 = theme().border,
|
|
252
256
|
_v$3 = theme().backgroundPanel,
|
|
253
257
|
_v$4 = theme().text,
|
|
254
|
-
_v$5 = theme().textMuted
|
|
258
|
+
_v$5 = theme().textMuted,
|
|
259
|
+
_v$6 = theme().textMuted;
|
|
255
260
|
_v$ !== _p$.e && (_p$.e = _$setProp(_el$, "height", _v$, _p$.e));
|
|
256
261
|
_v$2 !== _p$.t && (_p$.t = _$setProp(_el$, "borderColor", _v$2, _p$.t));
|
|
257
262
|
_v$3 !== _p$.a && (_p$.a = _$setProp(_el$, "backgroundColor", _v$3, _p$.a));
|
|
258
263
|
_v$4 !== _p$.o && (_p$.o = _$setProp(_el$3, "fg", _v$4, _p$.o));
|
|
259
|
-
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$
|
|
264
|
+
_v$5 !== _p$.i && (_p$.i = _$setProp(_el$6, "fg", _v$5, _p$.i));
|
|
265
|
+
_v$6 !== _p$.n && (_p$.n = _$setProp(_el$9, "fg", _v$6, _p$.n));
|
|
260
266
|
return _p$;
|
|
261
267
|
}, {
|
|
262
268
|
e: undefined,
|
|
263
269
|
t: undefined,
|
|
264
270
|
a: undefined,
|
|
265
271
|
o: undefined,
|
|
266
|
-
i: undefined
|
|
272
|
+
i: undefined,
|
|
273
|
+
n: undefined
|
|
267
274
|
});
|
|
268
275
|
return _el$;
|
|
269
276
|
})();
|
package/dist/tui/dialogs.js
CHANGED
|
@@ -89,7 +89,8 @@ export function newShell(api, store, open) {
|
|
|
89
89
|
cwd: store.project(),
|
|
90
90
|
title: command.slice(0, 60),
|
|
91
91
|
owner: {
|
|
92
|
-
project: store.project()
|
|
92
|
+
project: store.project(),
|
|
93
|
+
session: store.session()
|
|
93
94
|
},
|
|
94
95
|
reuse: true
|
|
95
96
|
}).then(info => {
|
|
@@ -135,6 +136,52 @@ export function restartDaemon(api, store) {
|
|
|
135
136
|
onCancel: () => api.ui.dialog.clear()
|
|
136
137
|
}));
|
|
137
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Stopping shells in bulk. Two commands rather than one, because "everything I can see" and
|
|
142
|
+
* "everything, including what I cannot" are different intentions — and the second one asks first.
|
|
143
|
+
*/
|
|
144
|
+
export function stopShells(api, store, reach) {
|
|
145
|
+
const list = (reach === "view" ? store.shells() : store.all()).filter(s => s.status === "running");
|
|
146
|
+
if (list.length === 0) {
|
|
147
|
+
api.ui.toast({
|
|
148
|
+
title: "Shells",
|
|
149
|
+
message: "Nothing is running.",
|
|
150
|
+
duration: 3000
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const stop = () => {
|
|
155
|
+
api.ui.dialog.clear();
|
|
156
|
+
void Promise.all(list.map(shell => store.client.call("shell.stop", {
|
|
157
|
+
id: shell.id,
|
|
158
|
+
graceMs: 2000
|
|
159
|
+
}).catch(() => {}))).then(() => {
|
|
160
|
+
void store.refresh();
|
|
161
|
+
api.ui.toast({
|
|
162
|
+
title: "Shells",
|
|
163
|
+
message: `Stopped ${list.length} shell${list.length === 1 ? "" : "s"}.`,
|
|
164
|
+
duration: 4000
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Shells from other conversations are the ones you are not looking at; say so before killing them.
|
|
170
|
+
const elsewhere = reach === "project" ? list.length - store.shells().filter(s => s.status === "running").length : 0;
|
|
171
|
+
if (elsewhere <= 0) {
|
|
172
|
+
stop();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const DialogConfirm = api.ui.DialogConfirm;
|
|
176
|
+
api.ui.dialog.replace(() => _$createComponent(DialogConfirm, {
|
|
177
|
+
title: "Stop every shell in this project?",
|
|
178
|
+
get message() {
|
|
179
|
+
return `${list.length} running · ${elsewhere} from other conversations.`;
|
|
180
|
+
},
|
|
181
|
+
onConfirm: stop,
|
|
182
|
+
onCancel: () => api.ui.dialog.clear()
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
138
185
|
export function pickShell(api, store, open) {
|
|
139
186
|
const DialogSelect = api.ui.DialogSelect;
|
|
140
187
|
const shells = order(store.shells());
|
package/dist/tui/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { loadConfig } from "../core/config.js";
|
|
|
11
11
|
import { Console } from "./components/console.js";
|
|
12
12
|
import { Dock } from "./components/dock.js";
|
|
13
13
|
import { SidebarShells } from "./components/sidebar.js";
|
|
14
|
-
import { announceUpdate, newShell, offerUpdate, pickShell, restartDaemon } from "./dialogs.js";
|
|
14
|
+
import { announceUpdate, newShell, offerUpdate, pickShell, restartDaemon, stopShells } from "./dialogs.js";
|
|
15
15
|
import { createShellStore } from "./state/store.js";
|
|
16
16
|
const DEFAULT_KEYS = {
|
|
17
17
|
"cockpit.shells.dock": "<leader>o",
|
|
@@ -55,7 +55,10 @@ const shellTui = async (api, rawOptions, meta) => {
|
|
|
55
55
|
...DEFAULT_KEYS,
|
|
56
56
|
...options.keybinds
|
|
57
57
|
});
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
// An explicit `ui.dockOpen` says how the panel should start; without one, whatever you last left
|
|
60
|
+
// it as. Remembered state that overrides a written setting is a setting that appears to do nothing.
|
|
61
|
+
const [dockOpen, setDockOpen] = createSignal(options.dockOpen ?? api.kv.get("cockpit.dock.open", false));
|
|
59
62
|
const toggleDock = () => {
|
|
60
63
|
const next = !dockOpen();
|
|
61
64
|
setDockOpen(next);
|
|
@@ -107,6 +110,20 @@ const shellTui = async (api, rawOptions, meta) => {
|
|
|
107
110
|
namespace: "palette",
|
|
108
111
|
slashName: "shell-new",
|
|
109
112
|
run: () => newShell(api, store, openConsole)
|
|
113
|
+
}, {
|
|
114
|
+
name: "cockpit.shells.stop",
|
|
115
|
+
title: "Stop the shells in view",
|
|
116
|
+
category: "Shells",
|
|
117
|
+
namespace: "palette",
|
|
118
|
+
slashName: "shells-stop",
|
|
119
|
+
run: () => stopShells(api, store, "view")
|
|
120
|
+
}, {
|
|
121
|
+
name: "cockpit.shells.stopAll",
|
|
122
|
+
title: "Stop every shell in this project",
|
|
123
|
+
category: "Shells",
|
|
124
|
+
namespace: "palette",
|
|
125
|
+
slashName: "shells-stop-all",
|
|
126
|
+
run: () => stopShells(api, store, "project")
|
|
110
127
|
}, {
|
|
111
128
|
name: "cockpit.shells.clear",
|
|
112
129
|
title: "Clear finished shells",
|
package/dist/tui/lib/view.js
CHANGED
|
@@ -81,13 +81,29 @@ export function statusDetail(s, now) {
|
|
|
81
81
|
case "done":
|
|
82
82
|
return `took ${ran} · ${ago}`;
|
|
83
83
|
case "stop":
|
|
84
|
-
return
|
|
84
|
+
return `${stopWord(s)} after ${ran} · ${ago}`;
|
|
85
85
|
case "fail":
|
|
86
86
|
if (s.status === "failed") return "could not start";
|
|
87
87
|
return `exit ${s.exitCode ?? "?"} after ${ran} · ${ago}`;
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Why it stopped, in one word, because the panel has room for exactly that. */
|
|
92
|
+
function stopWord(s) {
|
|
93
|
+
switch (s.stopReason) {
|
|
94
|
+
case "timeout":
|
|
95
|
+
return "timed out";
|
|
96
|
+
case "idle":
|
|
97
|
+
return "idle-stopped";
|
|
98
|
+
case "shutdown":
|
|
99
|
+
return "daemon stopped it";
|
|
100
|
+
case "request":
|
|
101
|
+
return s.stoppedBy?.includes("tui") ? "you stopped it" : "agent stopped it";
|
|
102
|
+
default:
|
|
103
|
+
return "stopped";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/** Short health label for a watched shell, e.g. "tsc ✗" — empty when nothing is watching it. */
|
|
92
108
|
export function watchLabel(s) {
|
|
93
109
|
const watch = s.watch;
|
package/dist/tui/state/store.js
CHANGED
|
@@ -2,6 +2,9 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { createEffect, createMemo, createRoot, createSignal, on, onCleanup } from "solid-js";
|
|
3
3
|
import { createStore, reconcile } from "solid-js/store";
|
|
4
4
|
import { order, partition } from "../lib/view.js";
|
|
5
|
+
|
|
6
|
+
/** A project's shells outnumber a conversation's, and mixing them is how the panel gets confusing. */
|
|
7
|
+
|
|
5
8
|
export function createShellStore(api, client, options = {}) {
|
|
6
9
|
return createRoot(dispose => {
|
|
7
10
|
const [state, setState] = createStore({
|
|
@@ -12,7 +15,12 @@ export function createShellStore(api, client, options = {}) {
|
|
|
12
15
|
const [frame, setFrame] = createSignal(0);
|
|
13
16
|
const [selectedId, setSelectedId] = createSignal();
|
|
14
17
|
const [showAll, setShowAll] = createSignal(api.kv.get("cockpit.shells.showAll", false));
|
|
18
|
+
const [scope, setScope] = createSignal(api.kv.get("cockpit.shells.scope", options.scope ?? "session"));
|
|
15
19
|
const project = () => api.state.path.directory;
|
|
20
|
+
const session = () => {
|
|
21
|
+
const route = api.route.current;
|
|
22
|
+
return route.name === "session" ? route.params.sessionID : undefined;
|
|
23
|
+
};
|
|
16
24
|
const historyMs = (options.historyMinutes ?? 30) * 60_000;
|
|
17
25
|
const refresh = async () => {
|
|
18
26
|
try {
|
|
@@ -53,7 +61,13 @@ export function createShellStore(api, client, options = {}) {
|
|
|
53
61
|
const ordered = order(state.list);
|
|
54
62
|
return ordered.find(s => s.id === selectedId()) ?? ordered[0];
|
|
55
63
|
});
|
|
56
|
-
|
|
64
|
+
/** Everything the daemon knows about this project, narrowed to what the panel is about. */
|
|
65
|
+
const inScope = createMemo(() => {
|
|
66
|
+
const current = session();
|
|
67
|
+
if (scope() === "project" || !current) return state.list;
|
|
68
|
+
return state.list.filter(shell => shell.owner.session === current);
|
|
69
|
+
});
|
|
70
|
+
const folded = createMemo(() => partition(inScope(), {
|
|
57
71
|
showAll: showAll(),
|
|
58
72
|
historyMs,
|
|
59
73
|
now: now(),
|
|
@@ -62,7 +76,15 @@ export function createShellStore(api, client, options = {}) {
|
|
|
62
76
|
return {
|
|
63
77
|
client,
|
|
64
78
|
project,
|
|
65
|
-
|
|
79
|
+
session,
|
|
80
|
+
scope,
|
|
81
|
+
toggleScope() {
|
|
82
|
+
const next = scope() === "session" ? "project" : "session";
|
|
83
|
+
setScope(next);
|
|
84
|
+
api.kv.set("cockpit.shells.scope", next);
|
|
85
|
+
},
|
|
86
|
+
shells: () => inScope(),
|
|
87
|
+
all: () => state.list,
|
|
66
88
|
visible: () => folded().visible,
|
|
67
89
|
hidden: () => folded().hidden,
|
|
68
90
|
showAll,
|
|
@@ -79,7 +101,7 @@ export function createShellStore(api, client, options = {}) {
|
|
|
79
101
|
// Cycles every shell in the project: folding only exists to keep the sidebar short, and a
|
|
80
102
|
// shell you cannot reach from the console would be a trap.
|
|
81
103
|
step(delta) {
|
|
82
|
-
const list = order(
|
|
104
|
+
const list = order(inScope());
|
|
83
105
|
if (list.length === 0) return;
|
|
84
106
|
const index = Math.max(0, list.findIndex(s => s.id === pick()?.id));
|
|
85
107
|
const next = list[(index + delta + list.length) % list.length];
|
|
@@ -135,7 +157,9 @@ export function useScreen(store, id) {
|
|
|
135
157
|
current = next;
|
|
136
158
|
setScreen(undefined);
|
|
137
159
|
if (!next) return;
|
|
138
|
-
|
|
160
|
+
// Not store.shells(): that is narrowed to the current conversation, and the selected shell may
|
|
161
|
+
// sit outside it. A missing offset here is what used to ask the daemon for bytes past the end.
|
|
162
|
+
const info = store.all().find(s => s.id === next);
|
|
139
163
|
void store.client.call("shell.attach", {
|
|
140
164
|
id: next,
|
|
141
165
|
fromOffset: info?.bytes ?? Number.MAX_SAFE_INTEGER
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opencode-cockpit/shell",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Background shells for OpenCode: the agent starts, waits on and drives PTYs; you watch them in a docked TUI panel",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"access": "public"
|
|
52
52
|
},
|
|
53
53
|
"dependencies": {
|
|
54
|
-
"@opencode-cockpit/client": "0.2.
|
|
55
|
-
"@opencode-cockpit/daemon": "0.2.
|
|
56
|
-
"@opencode-cockpit/protocol": "0.2.
|
|
54
|
+
"@opencode-cockpit/client": "0.2.2",
|
|
55
|
+
"@opencode-cockpit/daemon": "0.2.2",
|
|
56
|
+
"@opencode-cockpit/protocol": "0.2.2",
|
|
57
57
|
"@opencode-ai/plugin": "1.18.31"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import type { WatchRule } from "@opencode-cockpit/protocol/shell";
|
|
2
2
|
import type { CockpitConfig } from "../../core/config.ts";
|
|
3
|
-
|
|
4
|
-
* Turns a preset name into what `shell.watch` needs. A preset defined in config is sent as an
|
|
5
|
-
* explicit rule, so users can add tools or correct a built-in without touching the daemon.
|
|
6
|
-
*/
|
|
7
|
-
export declare function watchArgs(preset: string, config: CockpitConfig): {
|
|
3
|
+
export type WatchRequest = {
|
|
8
4
|
preset?: string;
|
|
9
5
|
rule?: WatchRule;
|
|
10
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* Turns whatever the model passed for `watch` into what `shell.watch` needs.
|
|
9
|
+
*
|
|
10
|
+
* `true`/`"auto"` asks the daemon to pick a preset, a name picks that preset — and a preset defined
|
|
11
|
+
* in config travels as an explicit rule, so users can add tools without touching the daemon. Rule
|
|
12
|
+
* objects arrive as JSON *strings* often enough (models write one, and tool args are not always
|
|
13
|
+
* parsed the way the schema says) that a string which looks like an object is parsed rather than
|
|
14
|
+
* handed on as a preset name nobody has.
|
|
15
|
+
*/
|
|
16
|
+
export declare function watchArgs(watch: unknown, config: CockpitConfig): WatchRequest;
|
|
17
|
+
/** A rule from an object, or from the JSON string a model sometimes writes instead. */
|
|
18
|
+
export declare function asWatchRule(value: unknown): WatchRule | undefined;
|
package/types/connect.d.ts
CHANGED
|
@@ -6,4 +6,4 @@ export declare function daemonEntry(): string;
|
|
|
6
6
|
* BUN_BE_BUN=1 so it runs on OpenCode's embedded Bun (ADR 0001). The expected build lets the
|
|
7
7
|
* client replace a daemon left running from older plugin code.
|
|
8
8
|
*/
|
|
9
|
-
export declare function createClient(name: string): CockpitClient;
|
|
9
|
+
export declare function createClient(name: string, instance?: string): CockpitClient;
|
package/types/core/config.d.ts
CHANGED
|
@@ -20,12 +20,23 @@ export interface CockpitConfig {
|
|
|
20
20
|
kinds?: Record<string, string>;
|
|
21
21
|
/** Applied to every shell the agent starts, unless the call says otherwise. */
|
|
22
22
|
defaults?: {
|
|
23
|
-
|
|
23
|
+
/** true/"auto", a preset name, or your own rule. */
|
|
24
|
+
watch?: boolean | string | WatchRule;
|
|
24
25
|
logFile?: boolean;
|
|
25
26
|
idleTimeoutSeconds?: number;
|
|
26
27
|
timeoutSeconds?: number;
|
|
27
28
|
notifyOnExit?: boolean;
|
|
28
29
|
};
|
|
30
|
+
/** When shells end by themselves. */
|
|
31
|
+
lifecycle?: {
|
|
32
|
+
/**
|
|
33
|
+
* What happens to this window's shells when it closes. `stopMine` stops them, `keep` leaves
|
|
34
|
+
* them running for the next window — which is how shells survive an OpenCode restart.
|
|
35
|
+
*/
|
|
36
|
+
onExit?: "stopMine" | "keep";
|
|
37
|
+
/** Stop a shell after this long with no window of its own connected. `0` never does. */
|
|
38
|
+
orphanAfterMinutes?: number;
|
|
39
|
+
};
|
|
29
40
|
/** What is allowed to interrupt the agent. */
|
|
30
41
|
notify?: {
|
|
31
42
|
exit?: boolean;
|
package/types/tui/dialogs.d.ts
CHANGED
|
@@ -7,4 +7,9 @@ export declare function announceUpdate(api: TuiPluginApi, meta: TuiPluginMeta, c
|
|
|
7
7
|
export declare function offerUpdate(api: TuiPluginApi, meta: TuiPluginMeta, current: string): void;
|
|
8
8
|
export declare function newShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void): void;
|
|
9
9
|
export declare function restartDaemon(api: TuiPluginApi, store: ShellStore): void;
|
|
10
|
+
/**
|
|
11
|
+
* Stopping shells in bulk. Two commands rather than one, because "everything I can see" and
|
|
12
|
+
* "everything, including what I cannot" are different intentions — and the second one asks first.
|
|
13
|
+
*/
|
|
14
|
+
export declare function stopShells(api: TuiPluginApi, store: ShellStore, reach: "view" | "project"): void;
|
|
10
15
|
export declare function pickShell(api: TuiPluginApi, store: ShellStore, open: (id?: string) => void): void;
|
package/types/tui/lib/view.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export declare function partition(list: readonly ShellInfo[], opts: PartitionOpt
|
|
|
39
39
|
signal?: string | undefined;
|
|
40
40
|
error?: string | undefined;
|
|
41
41
|
summary?: string | undefined;
|
|
42
|
+
stopReason?: "idle" | "request" | "shutdown" | "timeout" | undefined;
|
|
43
|
+
stoppedBy?: string | undefined;
|
|
42
44
|
startedAt: number;
|
|
43
45
|
endedAt?: number | undefined;
|
|
44
46
|
cols: number;
|
|
@@ -5,13 +5,20 @@ import { type Accessor } from "solid-js";
|
|
|
5
5
|
export interface ShellStore {
|
|
6
6
|
client: CockpitClient;
|
|
7
7
|
project: () => string;
|
|
8
|
-
/**
|
|
8
|
+
/** Shells the panel is about: the current conversation, or the project. */
|
|
9
9
|
shells: Accessor<ShellInfo[]>;
|
|
10
|
+
/** Every shell in the project, whatever the panel is showing. */
|
|
11
|
+
all: Accessor<ShellInfo[]>;
|
|
10
12
|
/** Ordered and folded for display: running, recent failures, plus the selection. */
|
|
11
13
|
visible: Accessor<ShellInfo[]>;
|
|
12
14
|
hidden: Accessor<ShellInfo[]>;
|
|
13
15
|
showAll: Accessor<boolean>;
|
|
14
16
|
toggleAll(): void;
|
|
17
|
+
/** Which shells the panel is about: the conversation you are in, or the whole project. */
|
|
18
|
+
scope: Accessor<Scope>;
|
|
19
|
+
toggleScope(): void;
|
|
20
|
+
/** The session the interface is currently showing, when it is showing one. */
|
|
21
|
+
session: Accessor<string | undefined>;
|
|
15
22
|
connected: Accessor<boolean>;
|
|
16
23
|
now: Accessor<number>;
|
|
17
24
|
/** Spinner frame index; advances only while something is running. */
|
|
@@ -23,9 +30,13 @@ export interface ShellStore {
|
|
|
23
30
|
clearFinished(): Promise<number>;
|
|
24
31
|
dispose(): void;
|
|
25
32
|
}
|
|
33
|
+
/** A project's shells outnumber a conversation's, and mixing them is how the panel gets confusing. */
|
|
34
|
+
export type Scope = "session" | "project";
|
|
26
35
|
export interface StoreOptions {
|
|
27
36
|
/** Failures stay in the default view this long after they end. */
|
|
28
37
|
historyMinutes?: number;
|
|
38
|
+
/** What the panel lists by default. */
|
|
39
|
+
scope?: Scope;
|
|
29
40
|
}
|
|
30
41
|
export declare function createShellStore(api: TuiPluginApi, client: CockpitClient, options?: StoreOptions): ShellStore;
|
|
31
42
|
/**
|