@bramblex/codex-workbench 0.1.17 → 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 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 pi. The backend layer is intentionally provider-based so additional agents can be added without changing the TUI workflow.
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,15 +131,18 @@ 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 their session directories.
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
 
144
+ Every provider owns the full workbench command surface it advertises: new, resume, fork, archive, unarchive, and delete. A provider can implement an operation through its native CLI, workbench metadata, or file operations, but callers should not need provider-specific fallback logic.
145
+
143
146
  ---
144
147
 
145
148
  ## CLI commands
@@ -163,6 +166,7 @@ cwb delete <session> --force
163
166
 
164
167
  cwb new --cwd ~/projects/foo --backend codex "Summarize this repo"
165
168
  cwb new --cwd ~/projects/foo --backend pi "Summarize this repo"
169
+ cwb new --cwd ~/projects/foo --backend opencode "Summarize this repo"
166
170
  cwb resume <session> "what was the conclusion about the rate limiter?"
167
171
 
168
172
  cwb dirs --cwd ~/projects
@@ -189,10 +193,13 @@ When you run `new` or `resume`, the selected backend takes over the terminal. Wh
189
193
  | `CODEX_SESSIONS_DIR` | `$CODEX_HOME/sessions` | Session JSONL files |
190
194
  | `PI_CODING_AGENT_DIR` | `~/.pi/agent` | pi coding agent data directory |
191
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 |
192
198
  | `CODEX_WORKBENCH_META` | unset | Legacy override for `CWB_META` |
193
199
  | `CODEX_WORKBENCH_CONFIG` | unset | Legacy override for `CWB_CONFIG` |
194
200
  | `CODEX_BIN` | auto-detected | Force a specific Codex executable |
195
201
  | `PI_BIN` | auto-detected | Force a specific pi executable |
202
+ | `OPENCODE_BIN` | auto-detected | Force a specific opencode executable |
196
203
 
197
204
  By default, codex-workbench discovers the `codex` binary through your login shell's `PATH`. Set `CODEX_BIN` to override.
198
205
 
@@ -220,6 +227,10 @@ Make sure you've run Codex at least once. Sessions are stored as `.jsonl` files
220
227
 
221
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.
222
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
+
223
234
  ### A backend is missing from doctor
224
235
 
225
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.
@@ -283,6 +294,7 @@ src/
283
294
  providers/
284
295
  codex.js # Codex provider
285
296
  pi.js # pi provider
297
+ opencode.js # opencode provider
286
298
  index.js # provider registry
287
299
  services/
288
300
  codex-runner.js # backward-compatible provider runner wrapper
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bramblex/codex-workbench",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Terminal workbench for browsing and managing local and SSH Codex sessions.",
5
5
  "license": "MIT",
6
6
  "repository": {
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
- const files = provider.getSessionFiles();
110
- console.log(` sessions: ${files.length} file${files.length === 1 ? '' : 's'}`);
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
  }
@@ -249,6 +249,14 @@ function runSessionCommand(command, session, args, inherit) {
249
249
  module.exports = {
250
250
  id: 'codex',
251
251
  label: 'Codex',
252
+ capabilities: {
253
+ new: true,
254
+ resume: true,
255
+ fork: true,
256
+ archive: true,
257
+ unarchive: true,
258
+ delete: true,
259
+ },
252
260
  isAvailable,
253
261
  getSessionFiles,
254
262
  parseSession,
@@ -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
+ };
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { spawn, spawnSync } = require('child_process');
10
10
  const { HOME, PI_CODING_AGENT_DIR } = require('../config');
11
- const { updateMetadata } = require('../model/metadata');
11
+ const { removeMetadata, updateMetadata } = require('../model/metadata');
12
12
 
13
13
  // ---------------------------------------------------------------------------
14
14
  // Paths
@@ -272,9 +272,9 @@ function runSessionCommand(command, session, args, inherit) {
272
272
  return runArgv(argv, cwd, inherit);
273
273
  }
274
274
  case 'delete': {
275
- // pi has no delete CLI – just remove the file
276
- // A force flag is handled by session-sources calling deleteSessionFile
277
- return -1; // signal that file-based deletion should be used
275
+ fs.unlinkSync(session.file);
276
+ removeMetadata(session);
277
+ return 0;
278
278
  }
279
279
  case 'archive':
280
280
  case 'unarchive': {
@@ -312,6 +312,14 @@ function resolveBin() {
312
312
  module.exports = {
313
313
  id: 'pi',
314
314
  label: 'pi',
315
+ capabilities: {
316
+ new: true,
317
+ resume: true,
318
+ fork: true,
319
+ archive: true,
320
+ unarchive: true,
321
+ delete: true,
322
+ },
315
323
  isAvailable,
316
324
  getSessionFiles,
317
325
  parseSession,
@@ -105,6 +105,7 @@ function providerSummary(provider) {
105
105
  return {
106
106
  id: provider.id,
107
107
  label: provider.label || provider.id,
108
+ capabilities: provider.capabilities || {},
108
109
  };
109
110
  }
110
111
 
@@ -121,19 +122,15 @@ function listSourceBackends(source) {
121
122
  .map((backend) => ({
122
123
  id: String(backend.id),
123
124
  label: backend.label ? String(backend.label) : String(backend.id),
125
+ capabilities: backend.capabilities && typeof backend.capabilities === 'object'
126
+ ? backend.capabilities
127
+ : {},
124
128
  }));
125
129
  }
126
130
 
127
131
  function runSourceSessionCommand(session, command, args) {
128
132
  if (!session.sourceRemote) {
129
- const status = runCodexCommand(command, session, args);
130
- // If the provider returns -1 (e.g. pi delete = file-based), fall through to file deletion
131
- if (status === -1) {
132
- const { deleteSessionFile } = require('../model/session-store');
133
- deleteSessionFile(session);
134
- return 0;
135
- }
136
- return status;
133
+ return runCodexCommand(command, session, args);
137
134
  }
138
135
  const source = configuredSourceOrThrow(session.sourceId);
139
136
  const tty = command === 'resume' || command === 'fork';
@@ -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 flagText = flags ? `[${flags}]` : '';
258
- return `${shortId(session.id)} ${String(session.turns).padStart(2)}t ${truncate(localTime(session.updatedAt), 18)} ${flagText} ${truncate(title, 88)}`;
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) => {