@bramblex/codex-workbench 0.1.18 → 0.1.19
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 +12 -2
- package/package.json +1 -1
- package/src/cli-output.js +13 -2
- package/src/model/session-store.js +4 -3
- package/src/providers/index.js +7 -1
- package/src/providers/opencode.js +231 -0
- package/src/ui/workbench.js +18 -7
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ codex-workbench is an **interactive terminal UI** for coding-agent sessions. Ins
|
|
|
18
18
|
|
|
19
19
|
It also connects to **remote machines over SSH**, so you can manage sessions across all your servers from a single pane of glass.
|
|
20
20
|
|
|
21
|
-
Built-in backends currently include [Codex](https://github.com/openai/codex) and
|
|
21
|
+
Built-in backends currently include [Codex](https://github.com/openai/codex), pi, and opencode. The backend layer is intentionally provider-based so additional agents can be added without changing the TUI workflow.
|
|
22
22
|
|
|
23
23
|
A handful of CLI subcommands are available for scripting, but the TUI is the product.
|
|
24
24
|
|
|
@@ -131,12 +131,13 @@ Remote backends are supported as long as the remote `cwb` can read them.
|
|
|
131
131
|
|
|
132
132
|
## Backends
|
|
133
133
|
|
|
134
|
-
codex-workbench auto-detects installed backends by checking
|
|
134
|
+
codex-workbench auto-detects installed backends by checking each backend's session storage.
|
|
135
135
|
|
|
136
136
|
| Backend | Sessions | Binary override | Notes |
|
|
137
137
|
|---------|----------|-----------------|-------|
|
|
138
138
|
| `codex` | `$CODEX_SESSIONS_DIR` or `~/.codex/sessions` | `CODEX_BIN` | Uses the Codex CLI for new, resume, fork, archive, unarchive, and delete. |
|
|
139
139
|
| `pi` | `$PI_CODING_AGENT_SESSION_DIR` or `$PI_CODING_AGENT_DIR/sessions` | `PI_BIN` | Uses the pi CLI for new, resume, and fork. Archive/unarchive use workbench metadata; delete removes the session file. |
|
|
140
|
+
| `opencode` | `$OPENCODE_DB`, `$OPENCODE_DATA_DIR/opencode.db`, or `~/.local/share/opencode/opencode.db` | `OPENCODE_BIN` | Uses the opencode CLI and database for list, new, resume, fork, archive, unarchive, and delete. |
|
|
140
141
|
|
|
141
142
|
Session metadata such as custom names, notes, and archive state is stored in workbench's own metadata file, not inside backend session files.
|
|
142
143
|
|
|
@@ -165,6 +166,7 @@ cwb delete <session> --force
|
|
|
165
166
|
|
|
166
167
|
cwb new --cwd ~/projects/foo --backend codex "Summarize this repo"
|
|
167
168
|
cwb new --cwd ~/projects/foo --backend pi "Summarize this repo"
|
|
169
|
+
cwb new --cwd ~/projects/foo --backend opencode "Summarize this repo"
|
|
168
170
|
cwb resume <session> "what was the conclusion about the rate limiter?"
|
|
169
171
|
|
|
170
172
|
cwb dirs --cwd ~/projects
|
|
@@ -191,10 +193,13 @@ When you run `new` or `resume`, the selected backend takes over the terminal. Wh
|
|
|
191
193
|
| `CODEX_SESSIONS_DIR` | `$CODEX_HOME/sessions` | Session JSONL files |
|
|
192
194
|
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi coding agent data directory |
|
|
193
195
|
| `PI_CODING_AGENT_SESSION_DIR` | `$PI_CODING_AGENT_DIR/sessions` | pi session JSONL files |
|
|
196
|
+
| `OPENCODE_DATA_DIR` | `~/.local/share/opencode` | opencode data directory |
|
|
197
|
+
| `OPENCODE_DB` | `$OPENCODE_DATA_DIR/opencode.db` | opencode SQLite database |
|
|
194
198
|
| `CODEX_WORKBENCH_META` | unset | Legacy override for `CWB_META` |
|
|
195
199
|
| `CODEX_WORKBENCH_CONFIG` | unset | Legacy override for `CWB_CONFIG` |
|
|
196
200
|
| `CODEX_BIN` | auto-detected | Force a specific Codex executable |
|
|
197
201
|
| `PI_BIN` | auto-detected | Force a specific pi executable |
|
|
202
|
+
| `OPENCODE_BIN` | auto-detected | Force a specific opencode executable |
|
|
198
203
|
|
|
199
204
|
By default, codex-workbench discovers the `codex` binary through your login shell's `PATH`. Set `CODEX_BIN` to override.
|
|
200
205
|
|
|
@@ -222,6 +227,10 @@ Make sure you've run Codex at least once. Sessions are stored as `.jsonl` files
|
|
|
222
227
|
|
|
223
228
|
Make sure you've run the pi coding agent at least once. Sessions are stored as `.jsonl` files under `$PI_CODING_AGENT_SESSION_DIR` or `$PI_CODING_AGENT_DIR/sessions`. Run `ls ~/.pi/agent/sessions/` to verify.
|
|
224
229
|
|
|
230
|
+
### No opencode sessions found
|
|
231
|
+
|
|
232
|
+
Make sure you've run opencode at least once. Sessions are stored in the SQLite database at `$OPENCODE_DB` or `$OPENCODE_DATA_DIR/opencode.db`. Run `opencode session list --format json` or `opencode db path` to verify.
|
|
233
|
+
|
|
225
234
|
### A backend is missing from doctor
|
|
226
235
|
|
|
227
236
|
Backends appear only when their session directory exists. For a new backend integration, add a provider under `src/providers/` with session discovery, parsing, binary discovery, and command routing.
|
|
@@ -285,6 +294,7 @@ src/
|
|
|
285
294
|
providers/
|
|
286
295
|
codex.js # Codex provider
|
|
287
296
|
pi.js # pi provider
|
|
297
|
+
opencode.js # opencode provider
|
|
288
298
|
index.js # provider registry
|
|
289
299
|
services/
|
|
290
300
|
codex-runner.js # backward-compatible provider runner wrapper
|
package/package.json
CHANGED
package/src/cli-output.js
CHANGED
|
@@ -31,9 +31,15 @@ Environment:
|
|
|
31
31
|
CWB_CONFIG default: $CWB_HOME/config.json
|
|
32
32
|
CODEX_HOME default: ~/.codex
|
|
33
33
|
CODEX_SESSIONS_DIR default: $CODEX_HOME/sessions
|
|
34
|
+
PI_CODING_AGENT_DIR default: ~/.pi/agent
|
|
35
|
+
PI_CODING_AGENT_SESSION_DIR default: $PI_CODING_AGENT_DIR/sessions
|
|
36
|
+
OPENCODE_DATA_DIR default: ~/.local/share/opencode
|
|
37
|
+
OPENCODE_DB default: $OPENCODE_DATA_DIR/opencode.db
|
|
34
38
|
CODEX_WORKBENCH_META legacy override for CWB_META
|
|
35
39
|
CODEX_WORKBENCH_CONFIG legacy override for CWB_CONFIG
|
|
36
40
|
CODEX_BIN default: codex from shell PATH
|
|
41
|
+
PI_BIN default: pi from shell PATH
|
|
42
|
+
OPENCODE_BIN default: opencode from shell PATH
|
|
37
43
|
`);
|
|
38
44
|
}
|
|
39
45
|
|
|
@@ -106,8 +112,13 @@ function printDoctor() {
|
|
|
106
112
|
try { provider.resolveBin(); } catch (err) { console.log(` error: ${err.message}`); }
|
|
107
113
|
}
|
|
108
114
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
115
|
+
if (provider.listSessions) {
|
|
116
|
+
const sessions = provider.listSessions();
|
|
117
|
+
console.log(` sessions: ${sessions.length}`);
|
|
118
|
+
} else {
|
|
119
|
+
const files = provider.getSessionFiles();
|
|
120
|
+
console.log(` sessions: ${files.length} file${files.length === 1 ? '' : 's'}`);
|
|
121
|
+
}
|
|
111
122
|
} catch (err) {
|
|
112
123
|
console.log(` sessions: error - ${err.message}`);
|
|
113
124
|
}
|
|
@@ -18,10 +18,10 @@ function listSessions() {
|
|
|
18
18
|
const fileEntries = getAllSessionFiles();
|
|
19
19
|
|
|
20
20
|
const sessions = [];
|
|
21
|
-
for (const { file, backend } of fileEntries) {
|
|
21
|
+
for (const { file, backend, session: listedSession } of fileEntries) {
|
|
22
22
|
try {
|
|
23
23
|
const provider = getProvider(backend);
|
|
24
|
-
const session = provider.parseSession(file);
|
|
24
|
+
const session = listedSession || provider.parseSession(file);
|
|
25
25
|
// Merge workbench metadata (name, note, archived)
|
|
26
26
|
const custom = meta.sessions[session.id] || {};
|
|
27
27
|
sessions.push({ ...session, ...custom });
|
|
@@ -45,7 +45,7 @@ function resolveSession(query, sessions) {
|
|
|
45
45
|
return session.id === query ||
|
|
46
46
|
session.id.startsWith(query) ||
|
|
47
47
|
session.name === query ||
|
|
48
|
-
path.basename(session.file) === query;
|
|
48
|
+
(session.file && path.basename(session.file) === query);
|
|
49
49
|
});
|
|
50
50
|
if (matches.length === 1) return matches[0];
|
|
51
51
|
if (matches.length === 0) throw new Error(`No session matched: ${query}`);
|
|
@@ -55,6 +55,7 @@ function resolveSession(query, sessions) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function deleteSessionFile(session) {
|
|
58
|
+
if (!session.file) throw new Error(`Session does not have a standalone file: ${session.id}`);
|
|
58
59
|
fs.unlinkSync(session.file);
|
|
59
60
|
removeMetadata(session);
|
|
60
61
|
}
|
package/src/providers/index.js
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// ---------------------------------------------------------------------------
|
|
6
6
|
|
|
7
7
|
const codex = require('./codex');
|
|
8
|
+
const opencode = require('./opencode');
|
|
8
9
|
const pi = require('./pi');
|
|
9
10
|
|
|
10
|
-
const ALL_PROVIDERS = [codex, pi];
|
|
11
|
+
const ALL_PROVIDERS = [codex, pi, opencode];
|
|
11
12
|
const providerMap = new Map(ALL_PROVIDERS.map((p) => [p.id, p]));
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -41,6 +42,10 @@ function providerForSession(session) {
|
|
|
41
42
|
function getAllSessionFiles() {
|
|
42
43
|
const files = [];
|
|
43
44
|
for (const provider of getAvailableProviders()) {
|
|
45
|
+
if (provider.listSessions) {
|
|
46
|
+
for (const session of provider.listSessions()) files.push({ session, backend: provider.id });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
44
49
|
for (const file of provider.getSessionFiles()) {
|
|
45
50
|
files.push({ file, backend: provider.id });
|
|
46
51
|
}
|
|
@@ -55,5 +60,6 @@ module.exports = {
|
|
|
55
60
|
providerForSession,
|
|
56
61
|
getAllSessionFiles,
|
|
57
62
|
codex,
|
|
63
|
+
opencode,
|
|
58
64
|
pi,
|
|
59
65
|
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawn, spawnSync } = require('child_process');
|
|
7
|
+
const { removeMetadata } = require('../model/metadata');
|
|
8
|
+
|
|
9
|
+
const HOME = os.homedir();
|
|
10
|
+
const OPENCODE_DATA_DIR = process.env.OPENCODE_DATA_DIR ||
|
|
11
|
+
path.join(process.env.XDG_DATA_HOME || path.join(HOME, '.local', 'share'), 'opencode');
|
|
12
|
+
const OPENCODE_DB = process.env.OPENCODE_DB || path.join(OPENCODE_DATA_DIR, 'opencode.db');
|
|
13
|
+
|
|
14
|
+
function isExecutable(file) {
|
|
15
|
+
try { fs.accessSync(file, fs.constants.X_OK); return true; } catch { return false; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findOnPath(command, pathValue) {
|
|
19
|
+
for (const dir of (pathValue || process.env.PATH || '').split(path.delimiter)) {
|
|
20
|
+
if (!dir) continue;
|
|
21
|
+
const candidate = path.join(dir, command);
|
|
22
|
+
if (isExecutable(candidate)) return candidate;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveOpenCodeBin() {
|
|
28
|
+
if (process.env.OPENCODE_BIN) {
|
|
29
|
+
if (isExecutable(process.env.OPENCODE_BIN)) return process.env.OPENCODE_BIN;
|
|
30
|
+
throw new Error(`OPENCODE_BIN is not executable: ${process.env.OPENCODE_BIN}`);
|
|
31
|
+
}
|
|
32
|
+
const fromPath = findOnPath('opencode', process.env.PATH);
|
|
33
|
+
if (fromPath) return fromPath;
|
|
34
|
+
throw new Error('Could not find the opencode executable. Set OPENCODE_BIN or add opencode to PATH.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function dbQuery(sql) {
|
|
38
|
+
const result = spawnSync(resolveOpenCodeBin(), ['db', sql, '--format', 'json'], {
|
|
39
|
+
encoding: 'utf8',
|
|
40
|
+
env: process.env,
|
|
41
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
42
|
+
});
|
|
43
|
+
if (result.error) throw result.error;
|
|
44
|
+
if (result.status !== 0) throw new Error((result.stderr || '').trim() || `opencode db exited with code ${result.status}`);
|
|
45
|
+
return JSON.parse(result.stdout || '[]');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sqlString(value) {
|
|
49
|
+
return `'${String(value).replace(/'/g, "''")}'`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseJson(value, fallback) {
|
|
53
|
+
try { return JSON.parse(value || ''); } catch { return fallback; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collectText(value, out = []) {
|
|
57
|
+
if (!value) return out;
|
|
58
|
+
if (typeof value === 'string') {
|
|
59
|
+
if (value.trim()) out.push(value.trim());
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
for (const item of value) collectText(item, out);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
if (typeof value === 'object') {
|
|
67
|
+
for (const key of ['text', 'content', 'prompt', 'message', 'title']) {
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(value, key)) collectText(value[key], out);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function messageRole(row, data) {
|
|
75
|
+
const type = String(row.type || data.role || data.type || '').toLowerCase();
|
|
76
|
+
if (type.includes('user') || type.includes('input')) return 'user';
|
|
77
|
+
if (type.includes('assistant') || type.includes('agent')) return 'assistant';
|
|
78
|
+
return type || 'message';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listMessages(sessionId) {
|
|
82
|
+
const rows = dbQuery(
|
|
83
|
+
`select type, data from session_message where session_id = ${sqlString(sessionId)} order by seq asc`
|
|
84
|
+
);
|
|
85
|
+
return rows.map((row) => {
|
|
86
|
+
const data = parseJson(row.data, {});
|
|
87
|
+
return {
|
|
88
|
+
role: messageRole(row, data),
|
|
89
|
+
text: collectText(data).join(' ').replace(/\s+/g, ' ').trim(),
|
|
90
|
+
};
|
|
91
|
+
}).filter((message) => message.text);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function firstUserText(messages) {
|
|
95
|
+
const message = messages.find((item) => item.role === 'user');
|
|
96
|
+
return message ? message.text : '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function lastText(messages, role) {
|
|
100
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
101
|
+
if (!role || messages[i].role === role) return messages[i].text;
|
|
102
|
+
}
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function millisToIso(value) {
|
|
107
|
+
const number = Number(value);
|
|
108
|
+
if (!Number.isFinite(number) || number <= 0) return null;
|
|
109
|
+
return new Date(number).toISOString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sessionFromRow(row) {
|
|
113
|
+
const messages = listMessages(row.id);
|
|
114
|
+
const turns = messages.filter((message) => message.role === 'user').length;
|
|
115
|
+
return {
|
|
116
|
+
id: row.id,
|
|
117
|
+
file: '',
|
|
118
|
+
cwd: row.directory || '(unknown)',
|
|
119
|
+
startedAt: millisToIso(row.time_created),
|
|
120
|
+
updatedAt: millisToIso(row.time_updated),
|
|
121
|
+
cliVersion: row.version || '',
|
|
122
|
+
provider: row.model || '',
|
|
123
|
+
turns,
|
|
124
|
+
first: row.title || firstUserText(messages),
|
|
125
|
+
last: lastText(messages, 'user'),
|
|
126
|
+
lastAssistant: lastText(messages, 'assistant'),
|
|
127
|
+
messages,
|
|
128
|
+
archived: Boolean(row.time_archived),
|
|
129
|
+
backend: 'opencode',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function listSessions() {
|
|
134
|
+
const rows = dbQuery(
|
|
135
|
+
'select id, title, directory, version, model, time_created, time_updated, time_archived from session order by time_updated desc'
|
|
136
|
+
);
|
|
137
|
+
return rows.map(sessionFromRow);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function usableCwd(dir) {
|
|
141
|
+
for (const candidate of [dir, process.cwd(), HOME]) {
|
|
142
|
+
if (!candidate || candidate === '(unknown)') continue;
|
|
143
|
+
try { if (fs.statSync(candidate).isDirectory()) return candidate; } catch { /* skip */ }
|
|
144
|
+
}
|
|
145
|
+
return HOME;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function runArgv(argv, cwd, inherit) {
|
|
149
|
+
if (inherit) {
|
|
150
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: 'inherit', cwd, env: process.env });
|
|
151
|
+
child.on('error', (err) => { console.error(`error: failed to start opencode: ${err.message}`); process.exit(1); });
|
|
152
|
+
child.on('exit', (code, signal) => { if (signal) process.kill(process.pid, signal); process.exit(code || 0); });
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const result = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit', cwd, env: process.env });
|
|
156
|
+
if (result.error) throw new Error(`failed to start opencode: ${result.error.message}`);
|
|
157
|
+
const status = typeof result.status === 'number' ? result.status : 1;
|
|
158
|
+
process.exitCode = status;
|
|
159
|
+
return status;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function runSessionCommand(command, session, args, inherit) {
|
|
163
|
+
const executable = resolveOpenCodeBin();
|
|
164
|
+
const cwd = usableCwd(session.cwd);
|
|
165
|
+
switch (command) {
|
|
166
|
+
case 'resume':
|
|
167
|
+
{
|
|
168
|
+
const argv = [executable, cwd, '--session', session.id];
|
|
169
|
+
if (args && args.length) argv.push('--prompt', args.join(' '));
|
|
170
|
+
return runArgv(argv, cwd, inherit);
|
|
171
|
+
}
|
|
172
|
+
case 'fork':
|
|
173
|
+
return runArgv([executable, cwd, '--session', session.id, '--fork'], cwd, inherit);
|
|
174
|
+
case 'delete': {
|
|
175
|
+
const status = runArgv([executable, 'session', 'delete', session.id], cwd, false);
|
|
176
|
+
if (status === 0) removeMetadata(session);
|
|
177
|
+
return status;
|
|
178
|
+
}
|
|
179
|
+
case 'archive':
|
|
180
|
+
dbQuery(`update session set time_archived = ${Date.now()} where id = ${sqlString(session.id)}`);
|
|
181
|
+
return 0;
|
|
182
|
+
case 'unarchive':
|
|
183
|
+
dbQuery(`update session set time_archived = null where id = ${sqlString(session.id)}`);
|
|
184
|
+
return 0;
|
|
185
|
+
default:
|
|
186
|
+
throw new Error(`Unknown command for opencode backend: ${command}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function runNew(cwd, args, inherit) {
|
|
191
|
+
const resolvedCwd = usableCwd(cwd);
|
|
192
|
+
const argv = [resolveOpenCodeBin(), resolvedCwd];
|
|
193
|
+
if (args && args.length) argv.push('--prompt', args.join(' '));
|
|
194
|
+
return runArgv(argv, resolvedCwd, inherit);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isAvailable() {
|
|
198
|
+
try {
|
|
199
|
+
resolveOpenCodeBin();
|
|
200
|
+
return fs.statSync(OPENCODE_DB).isFile();
|
|
201
|
+
} catch {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getSessionFiles() {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function resolveBin() {
|
|
211
|
+
try { return resolveOpenCodeBin(); } catch { return null; }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
id: 'opencode',
|
|
216
|
+
label: 'opencode',
|
|
217
|
+
capabilities: {
|
|
218
|
+
new: true,
|
|
219
|
+
resume: true,
|
|
220
|
+
fork: true,
|
|
221
|
+
archive: true,
|
|
222
|
+
unarchive: true,
|
|
223
|
+
delete: true,
|
|
224
|
+
},
|
|
225
|
+
isAvailable,
|
|
226
|
+
getSessionFiles,
|
|
227
|
+
listSessions,
|
|
228
|
+
resolveBin,
|
|
229
|
+
runCommand: runSessionCommand,
|
|
230
|
+
runNew,
|
|
231
|
+
};
|
package/src/ui/workbench.js
CHANGED
|
@@ -94,6 +94,7 @@ async function runWorkbench() {
|
|
|
94
94
|
mouse: true,
|
|
95
95
|
keys: true,
|
|
96
96
|
vi: false,
|
|
97
|
+
tags: true,
|
|
97
98
|
scrollbar: { ch: ' ', track: { bg: 'black' }, style: { bg: 'cyan' } },
|
|
98
99
|
style: {
|
|
99
100
|
border: { fg: 'cyan' },
|
|
@@ -225,6 +226,18 @@ async function runWorkbench() {
|
|
|
225
226
|
|
|
226
227
|
const styledListLabel = (color, text) => `{${color}-fg}{bold}${blessed.escape(text)}{/}`;
|
|
227
228
|
|
|
229
|
+
const backendThemes = {
|
|
230
|
+
codex: 'cyan',
|
|
231
|
+
pi: 'magenta',
|
|
232
|
+
opencode: 'green',
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const backendLabel = (backend, width = 0) => {
|
|
236
|
+
const text = backend || 'unknown';
|
|
237
|
+
const color = backendThemes[text] || 'yellow';
|
|
238
|
+
return `{${color}-fg}{bold}${blessed.escape(text.padEnd(width))}{/}`;
|
|
239
|
+
};
|
|
240
|
+
|
|
228
241
|
const machineLabel = (source, count) => {
|
|
229
242
|
const shortcut = sourceShortcut(source);
|
|
230
243
|
const prefix = shortcut ? `${shortcut} ` : '';
|
|
@@ -248,14 +261,12 @@ async function runWorkbench() {
|
|
|
248
261
|
};
|
|
249
262
|
|
|
250
263
|
const sessionLabel = (session) => {
|
|
251
|
-
const flags = [
|
|
252
|
-
session.backend || '',
|
|
253
|
-
session.name ? 'renamed' : '',
|
|
254
|
-
session.note ? 'note' : '',
|
|
255
|
-
].filter(Boolean).join(',');
|
|
256
264
|
const title = session.name || session.first || session.last || '(no prompt)';
|
|
257
|
-
const
|
|
258
|
-
|
|
265
|
+
const width = Math.max(24, (screen.width || 80) - projectWidth - 8);
|
|
266
|
+
const backendWidth = Math.max(8, Math.min(12, String(session.backend || 'unknown').length + 2));
|
|
267
|
+
const time = truncate(localTime(session.updatedAt), 18).padEnd(18);
|
|
268
|
+
const detailWidth = Math.max(12, width - backendWidth - 22);
|
|
269
|
+
return `${backendLabel(session.backend, backendWidth)} ${time} ${blessed.escape(truncate(title, detailWidth))}`;
|
|
259
270
|
};
|
|
260
271
|
|
|
261
272
|
const detailContent = (session) => {
|