terminal-agent-workboard 0.0.1

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.
@@ -0,0 +1,24 @@
1
+ {
2
+ "tmuxPrefix": "awb",
3
+ "defaultAgent": "codex",
4
+ "agents": {
5
+ "codex": {
6
+ "label": "Codex",
7
+ "command": "codex",
8
+ "resumeCommand": "codex resume {sessionId}"
9
+ },
10
+ "claude": {
11
+ "label": "Claude Code",
12
+ "command": "claude",
13
+ "resumeCommand": "claude --resume {sessionId}"
14
+ },
15
+ "opencode": {
16
+ "label": "OpenCode",
17
+ "command": "opencode"
18
+ },
19
+ "gemini": {
20
+ "label": "Gemini CLI",
21
+ "command": "gemini"
22
+ }
23
+ }
24
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lellansin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # terminal-agent-workboard
2
+
3
+ `workboard` is a tmux-backed Node.js + TypeScript prototype for managing multiple code-agent conversations from one terminal workboard.
4
+
5
+ It can run sessions for tools such as Claude Code, Codex, OpenCode, and Gemini CLI, then group them by inferred state:
6
+
7
+ - Working
8
+ - Needs input
9
+ - Rate limited (agent hit a quota / rate limit modal — switch model or wait for reset)
10
+ - Completed
11
+ - Return pending
12
+ - WIP
13
+ - Human handoff
14
+ - Session switching
15
+ - Agent routing
16
+
17
+ ## Requirements
18
+
19
+ - Node.js 20+
20
+ - tmux
21
+ - Any agent CLI you want to route to, such as `codex`, `claude`, `opencode`, or `gemini`
22
+
23
+ ## Tech Stack
24
+
25
+ - Node.js + TypeScript
26
+ - React + Ink for terminal rendering
27
+ - tmux for agent process/session management
28
+
29
+ ## Status detection
30
+
31
+ `workboard` polls each tmux pane and infers a status by matching common agent UI patterns. Notably:
32
+
33
+ - `Needs input` — Codex/Claude prompt sitting idle after an assistant reply, or an initialization prompt (e.g. Codex "Update available · press enter to continue") blocks startup.
34
+ - `Rate limited` — codex shows "Approaching rate limits" model-switch modal, or the agent output contains usage-limit / quota-exhausted phrasing. Open the session with `Ctrl+O` to choose a model or wait for the quota window to reset.
35
+
36
+ ## History Discovery
37
+
38
+ The default UI shows a bordered `Current Session` panel with the selected session's latest message. Long messages are truncated in the panel; use `Ctrl+O` or `/open` to inspect the full tmux session. History is hidden by default and can be opened with `/history`.
39
+
40
+ The history browser reads JSONL history files only:
41
+
42
+ - `~/.codex/session_index.jsonl`
43
+ - `~/.claude/projects/**/*.jsonl`
44
+
45
+ Markdown files such as Claude plan documents are intentionally ignored.
46
+
47
+ On startup, `workboard` initializes `~/.workboard/state.json`. This global state stores mappings between JSONL history ids and tmux sessions, so `/resume <history-id>` can return to the existing tmux session instead of creating a duplicate.
48
+
49
+ Inside `/history`:
50
+
51
+ - `↑` / `↓` or `j` / `k`: select a history entry
52
+ - `Enter`: resume the selected history session
53
+ - `Esc`: close history and return to the prompt
54
+
55
+ ## Setup
56
+
57
+ ```sh
58
+ npm install
59
+ npm run build
60
+ npm link
61
+ ```
62
+
63
+ Optional config:
64
+
65
+ ```sh
66
+ cp .agent-workboard.example.json .agent-workboard.json
67
+ ```
68
+
69
+ Edit `.agent-workboard.json` if your agent commands differ.
70
+
71
+ ## Run
72
+
73
+ ```sh
74
+ npm run dev
75
+ ```
76
+
77
+ After `npm link`, use the bin directly:
78
+
79
+ ```sh
80
+ workboard
81
+ ```
82
+
83
+ Useful flags:
84
+
85
+ ```sh
86
+ npm run dev -- --demo
87
+ npm run dev -- new codex "review this repository"
88
+ npm run dev -- list
89
+ workboard --demo
90
+ workboard new codex "review this repository"
91
+ workboard list
92
+ ```
93
+
94
+ ## Controls
95
+
96
+ - `↑` / `↓` or `j` / `k`: select a session
97
+ - `Enter`: send the bottom input to the selected session
98
+ - `/`: open the slash-command menu
99
+ - `Shift+Enter`: insert a newline in the prompt
100
+ - `←` / `→`: move the prompt cursor
101
+ - `Home` / `End`: move to line start/end
102
+ - `Alt+←` / `Alt+→` or `Ctrl+←` / `Ctrl+→`: move by word
103
+ - `Backspace` / `Delete`: delete before/after the cursor
104
+ - `Ctrl+W`: delete the previous word
105
+ - `Ctrl+K`: delete to the end of the line
106
+ - `Ctrl+N`: mark bottom input as a new-session task
107
+ - `Ctrl+O`: attach to the selected tmux session
108
+ - `Ctrl+X`: stop the selected session
109
+ - `Ctrl+C`: clear the prompt; press again on an empty prompt to quit
110
+
111
+ Commands typed into the input:
112
+
113
+ ```text
114
+ /new codex implement login flow
115
+ /new claude inspect failing tests
116
+ /history
117
+ /resume 019dab7d
118
+ /review
119
+ /send payment-migration continue
120
+ /select pr-review
121
+ /open
122
+ /kill
123
+ /refresh
124
+ ```
125
+
126
+ Without a slash command, pressing `Enter` sends the input to the selected conversation. If nothing is selected, it creates a new session with the default agent.
127
+ If the selected conversation no longer has a running tmux session, plain input creates a new session instead of sending to the dead session.
128
+
129
+ History restore uses a stable tmux session name. `workboard` first checks whether that tmux session already exists; if not, it creates one with the configured `resumeCommand`.
130
+
131
+ Resume command templates can use:
132
+
133
+ ```json
134
+ {
135
+ "resumeCommand": "codex resume {sessionId}"
136
+ }
137
+ ```
138
+
139
+ Available placeholders: `{sessionId}`, `{sourcePath}`, `{cwd}`, `{title}`.
@@ -0,0 +1,14 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ export function SlashCommandMenu({ items, activeIndex, width, maxVisible = 7 }) {
4
+ if (items.length === 0)
5
+ return null;
6
+ const visibleStart = Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, items.length - maxVisible));
7
+ const visible = items.slice(visibleStart, visibleStart + maxVisible);
8
+ const labelWidth = Math.min(Math.max(14, ...visible.map((item) => item.label.length + 2)), Math.max(14, Math.floor(width / 2)));
9
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [visible.map((item, index) => {
10
+ const actualIndex = visibleStart + index;
11
+ const active = actualIndex === activeIndex;
12
+ return (_jsxs(Box, { gap: 2, children: [_jsx(Box, { width: labelWidth, children: _jsxs(Text, { color: active ? "cyan" : undefined, bold: active, wrap: "truncate-end", children: [active ? "› " : " ", item.label] }) }), _jsx(Text, { color: active ? "cyan" : "gray", wrap: "truncate-end", children: item.description })] }, item.name));
13
+ }), _jsxs(Text, { color: "gray", children: ["(", activeIndex + 1, "/", items.length, ") \u2191\u2193 select \u00B7 Enter apply \u00B7 Esc close"] })] }));
14
+ }
package/dist/config.js ADDED
@@ -0,0 +1,34 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ const defaultConfig = {
4
+ tmuxPrefix: "awb",
5
+ defaultAgent: "codex",
6
+ agents: {
7
+ codex: { label: "Codex", command: "codex", resumeCommand: "codex resume {sessionId}" },
8
+ claude: { label: "Claude Code", command: "claude", resumeCommand: "claude --resume {sessionId}" },
9
+ opencode: { label: "OpenCode", command: "opencode" },
10
+ gemini: { label: "Gemini CLI", command: "gemini" }
11
+ }
12
+ };
13
+ export function loadConfig(cwd) {
14
+ const path = join(cwd, ".agent-workboard.json");
15
+ if (!existsSync(path)) {
16
+ return defaultConfig;
17
+ }
18
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
19
+ return {
20
+ ...defaultConfig,
21
+ ...parsed,
22
+ agents: {
23
+ ...defaultConfig.agents,
24
+ ...(parsed.agents ?? {})
25
+ }
26
+ };
27
+ }
28
+ export function ensureConfigExample(cwd) {
29
+ const path = join(cwd, ".agent-workboard.example.json");
30
+ if (existsSync(path))
31
+ return;
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, `${JSON.stringify(defaultConfig, null, 2)}\n`);
34
+ }
@@ -0,0 +1,21 @@
1
+ export function decideCtrlC(inputText, pendingExitAt, now, timeoutMs = 2000) {
2
+ if (inputText.length > 0) {
3
+ return {
4
+ action: "clearPrompt",
5
+ message: "prompt cleared; press ctrl+c again to quit",
6
+ pendingExitAt: null
7
+ };
8
+ }
9
+ if (pendingExitAt !== null && now - pendingExitAt <= timeoutMs) {
10
+ return {
11
+ action: "exit",
12
+ message: "exiting",
13
+ pendingExitAt: null
14
+ };
15
+ }
16
+ return {
17
+ action: "promptExit",
18
+ message: "press ctrl+c again to quit",
19
+ pendingExitAt: now
20
+ };
21
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ export class GlobalStateStore {
5
+ dir;
6
+ path;
7
+ constructor(dir = defaultWorkboardHome()) {
8
+ this.dir = dir;
9
+ this.path = join(dir, "state.json");
10
+ }
11
+ init() {
12
+ mkdirSync(this.dir, { recursive: true });
13
+ if (!existsSync(this.path)) {
14
+ this.writeMappings([]);
15
+ }
16
+ }
17
+ readMappings() {
18
+ if (!existsSync(this.path))
19
+ return [];
20
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
21
+ return parsed.mappings ?? [];
22
+ }
23
+ findByHistoryId(historyId) {
24
+ return this.readMappings().find((mapping) => mapping.historyId === historyId);
25
+ }
26
+ upsertMapping(mapping) {
27
+ const mappings = this.readMappings();
28
+ const index = mappings.findIndex((candidate) => candidate.historyId === mapping.historyId);
29
+ if (index >= 0) {
30
+ mappings[index] = mapping;
31
+ }
32
+ else {
33
+ mappings.push(mapping);
34
+ }
35
+ this.writeMappings(mappings);
36
+ }
37
+ writeMappings(mappings) {
38
+ mkdirSync(dirname(this.path), { recursive: true });
39
+ writeFileSync(this.path, `${JSON.stringify({ mappings }, null, 2)}\n`);
40
+ }
41
+ }
42
+ export function defaultWorkboardHome() {
43
+ return process.env.WORKBOARD_HOME || join(homedir(), ".workboard");
44
+ }
@@ -0,0 +1,148 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { ageLabel } from "./status.js";
5
+ export function discoverHistory(limit = 12) {
6
+ return [...discoverCodexHistory(limit), ...discoverClaudeHistory(limit)]
7
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
8
+ .slice(0, limit);
9
+ }
10
+ function discoverCodexHistory(limit) {
11
+ const path = join(homedir(), ".codex", "session_index.jsonl");
12
+ if (!existsSync(path))
13
+ return [];
14
+ return readJsonl(path)
15
+ .map((item) => {
16
+ const id = asString(item.id) ?? basename(path);
17
+ const updatedAt = asString(item.updated_at) ?? new Date(statSync(path).mtimeMs).toISOString();
18
+ const title = asString(item.thread_name) ?? id;
19
+ return toEntry({ id, agent: "codex", title, updatedAt, sourcePath: path });
20
+ })
21
+ .filter((entry) => Boolean(entry))
22
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
23
+ .slice(0, limit);
24
+ }
25
+ function discoverClaudeHistory(limit) {
26
+ const root = join(homedir(), ".claude", "projects");
27
+ if (!existsSync(root))
28
+ return [];
29
+ return findJsonlFiles(root, 5)
30
+ .filter((path) => !path.includes("/subagents/"))
31
+ .map((path) => parseClaudeJsonl(path))
32
+ .filter((entry) => Boolean(entry))
33
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
34
+ .slice(0, limit);
35
+ }
36
+ function parseClaudeJsonl(path) {
37
+ const fallbackId = basename(path, ".jsonl");
38
+ const fallbackTime = new Date(statSync(path).mtimeMs).toISOString();
39
+ let cwd;
40
+ let updatedAt = fallbackTime;
41
+ let sessionId = fallbackId;
42
+ let title = "";
43
+ for (const item of readJsonl(path, 120)) {
44
+ const timestamp = asString(item.timestamp);
45
+ if (timestamp)
46
+ updatedAt = timestamp;
47
+ cwd = asString(item.cwd) ?? cwd;
48
+ sessionId = asString(item.sessionId) ?? sessionId;
49
+ if (item.type === "user") {
50
+ const message = asObject(item.message);
51
+ const content = extractText(message?.content);
52
+ if (content && !isEnvironmentContext(content)) {
53
+ title = content;
54
+ break;
55
+ }
56
+ }
57
+ }
58
+ if (!title) {
59
+ title = fallbackId;
60
+ }
61
+ return toEntry({
62
+ id: sessionId,
63
+ agent: "claude",
64
+ title,
65
+ cwd: cwd ?? decodeClaudeProjectPath(dirname(path)),
66
+ updatedAt,
67
+ sourcePath: path
68
+ });
69
+ }
70
+ function findJsonlFiles(root, maxDepth) {
71
+ const files = [];
72
+ const visit = (dir, depth) => {
73
+ if (depth > maxDepth)
74
+ return;
75
+ for (const entry of safeReadDir(dir)) {
76
+ const path = join(dir, entry.name);
77
+ if (entry.isDirectory()) {
78
+ visit(path, depth + 1);
79
+ }
80
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
81
+ files.push(path);
82
+ }
83
+ }
84
+ };
85
+ visit(root, 0);
86
+ return files
87
+ .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
88
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
89
+ .slice(0, 200)
90
+ .map((item) => item.path);
91
+ }
92
+ function readJsonl(path, maxLines = Number.POSITIVE_INFINITY) {
93
+ return readFileSync(path, "utf8")
94
+ .split(/\r?\n/)
95
+ .slice(0, maxLines)
96
+ .map((line) => line.trim())
97
+ .filter(Boolean)
98
+ .map((line) => {
99
+ try {
100
+ return JSON.parse(line);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ })
106
+ .filter((item) => Boolean(item));
107
+ }
108
+ function safeReadDir(path) {
109
+ try {
110
+ return readdirSync(path, { withFileTypes: true, encoding: "utf8" });
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ }
116
+ function toEntry(input) {
117
+ const title = input.title.trim().replace(/\s+/g, " ").slice(0, 120);
118
+ if (!title)
119
+ return null;
120
+ return {
121
+ ...input,
122
+ title,
123
+ ageLabel: ageLabel(input.updatedAt)
124
+ };
125
+ }
126
+ function extractText(value) {
127
+ if (typeof value === "string")
128
+ return value;
129
+ if (Array.isArray(value)) {
130
+ return value.map((item) => extractText(asObject(item)?.text)).filter(Boolean).join(" ");
131
+ }
132
+ return "";
133
+ }
134
+ function decodeClaudeProjectPath(path) {
135
+ const encoded = basename(path);
136
+ if (!encoded.startsWith("-"))
137
+ return undefined;
138
+ return encoded.replace(/-/g, "/");
139
+ }
140
+ function isEnvironmentContext(text) {
141
+ return text.trim().startsWith("<environment_context>");
142
+ }
143
+ function asObject(value) {
144
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
145
+ }
146
+ function asString(value) {
147
+ return typeof value === "string" && value.trim() ? value : undefined;
148
+ }
package/dist/index.js ADDED
@@ -0,0 +1,87 @@
1
+ #!/usr/bin/env node
2
+ import { loadConfig } from "./config.js";
3
+ import { SessionStore } from "./store.js";
4
+ import { Tmux } from "./tmux.js";
5
+ import { Workboard } from "./workboard.js";
6
+ import { GlobalStateStore } from "./globalState.js";
7
+ import React from "react";
8
+ import { render } from "ink";
9
+ import { WorkboardApp } from "./ui.js";
10
+ const cwd = process.cwd();
11
+ const config = loadConfig(cwd);
12
+ const store = new SessionStore(cwd);
13
+ const globalState = new GlobalStateStore();
14
+ const tmux = new Tmux();
15
+ const workboard = new Workboard(cwd, config, store, tmux, globalState);
16
+ async function main() {
17
+ globalState.init();
18
+ if (!tmux.available()) {
19
+ throw new Error("tmux is required but was not found on PATH.");
20
+ }
21
+ const [command, ...args] = process.argv.slice(2);
22
+ if (command === "new") {
23
+ const [agent, ...taskParts] = args;
24
+ const task = taskParts.join(" ");
25
+ if (!task)
26
+ throw new Error("Usage: workboard new <agent> <task>");
27
+ const session = workboard.create(agent, task);
28
+ console.log(`created ${session.id} (${session.tmuxSession})`);
29
+ return;
30
+ }
31
+ if (command === "send") {
32
+ const [id, ...inputParts] = args;
33
+ if (!id || inputParts.length === 0)
34
+ throw new Error("Usage: workboard send <session> <input>");
35
+ workboard.send(id, inputParts.join(" "));
36
+ console.log(`sent to ${id}`);
37
+ return;
38
+ }
39
+ if (command === "list") {
40
+ for (const session of workboard.list()) {
41
+ console.log(`${session.status.padEnd(17)} ${session.id.padEnd(32)} ${session.agent.padEnd(10)} ${session.summary}`);
42
+ }
43
+ return;
44
+ }
45
+ if (command === "open") {
46
+ const [id] = args;
47
+ if (!id)
48
+ throw new Error("Usage: workboard open <session>");
49
+ workboard.attach(id);
50
+ }
51
+ if (command === "--demo") {
52
+ seedDemo(store, config.tmuxPrefix);
53
+ }
54
+ if (!process.stdin.isTTY) {
55
+ throw new Error("workboard requires an interactive terminal (TTY).");
56
+ }
57
+ render(React.createElement(WorkboardApp, { workboard, config }), { exitOnCtrlC: false });
58
+ }
59
+ function seedDemo(store, prefix) {
60
+ const now = new Date();
61
+ store.write([
62
+ demo("dark-mode", "codex", "system theme vs explicit toggle - your call", "Needs input", 4, prefix, now),
63
+ demo("release-notes", "claude", "draft ready - which feature leads?", "Needs input", 11, prefix, now),
64
+ demo("load-test", "gemini", "-> to return", "Return pending", 3, prefix, now),
65
+ demo("pr-review", "codex", "-> to return", "Working", 0, prefix, now),
66
+ demo("perf-audit", "opencode", "events_org_ts index live - p95 38ms", "Working", 7, prefix, now),
67
+ demo("payment-migration", "claude", "porting billing to the new processor - 12/14", "WIP", 2, prefix, now),
68
+ demo("onboarding-copy", "gemini", "rewriting empty-state copy across 6 screens", "Working", 1, prefix, now),
69
+ demo("test-coverage", "codex", "billing/ from 61% -> 92% - PR #408 merged", "Completed", 9, prefix, now)
70
+ ]);
71
+ }
72
+ function demo(id, agent, title, statusOverride, ageMinutes, prefix, now) {
73
+ const updatedAt = new Date(now.getTime() - ageMinutes * 60_000).toISOString();
74
+ return {
75
+ id,
76
+ title,
77
+ agent,
78
+ tmuxSession: `${prefix}-${id}`,
79
+ createdAt: updatedAt,
80
+ updatedAt,
81
+ statusOverride
82
+ };
83
+ }
84
+ main().catch((error) => {
85
+ console.error(error instanceof Error ? error.message : String(error));
86
+ process.exitCode = 1;
87
+ });
@@ -0,0 +1,102 @@
1
+ export const EMPTY_BUFFER = { text: "", cursor: 0 };
2
+ export function insertText(state, value) {
3
+ if (!value)
4
+ return state;
5
+ const text = state.text.slice(0, state.cursor) + value + state.text.slice(state.cursor);
6
+ return { text, cursor: state.cursor + value.length };
7
+ }
8
+ export function backspace(state) {
9
+ if (state.cursor === 0)
10
+ return state;
11
+ return {
12
+ text: state.text.slice(0, state.cursor - 1) + state.text.slice(state.cursor),
13
+ cursor: state.cursor - 1
14
+ };
15
+ }
16
+ export function deleteForward(state) {
17
+ if (state.cursor >= state.text.length)
18
+ return state;
19
+ return {
20
+ text: state.text.slice(0, state.cursor) + state.text.slice(state.cursor + 1),
21
+ cursor: state.cursor
22
+ };
23
+ }
24
+ export function deleteWordBefore(state) {
25
+ const end = state.cursor;
26
+ let start = end;
27
+ while (start > 0 && /\s/.test(state.text[start - 1] ?? ""))
28
+ start--;
29
+ while (start > 0 && !/\s/.test(state.text[start - 1] ?? ""))
30
+ start--;
31
+ return {
32
+ text: state.text.slice(0, start) + state.text.slice(end),
33
+ cursor: start
34
+ };
35
+ }
36
+ export function moveLeft(state) {
37
+ return state.cursor === 0 ? state : { ...state, cursor: state.cursor - 1 };
38
+ }
39
+ export function moveRight(state) {
40
+ return state.cursor >= state.text.length ? state : { ...state, cursor: state.cursor + 1 };
41
+ }
42
+ export function moveWordLeft(state) {
43
+ let cursor = state.cursor;
44
+ while (cursor > 0 && /\s/.test(state.text[cursor - 1] ?? ""))
45
+ cursor--;
46
+ while (cursor > 0 && !/\s/.test(state.text[cursor - 1] ?? ""))
47
+ cursor--;
48
+ return { ...state, cursor };
49
+ }
50
+ export function moveWordRight(state) {
51
+ let cursor = state.cursor;
52
+ while (cursor < state.text.length && /\s/.test(state.text[cursor] ?? ""))
53
+ cursor++;
54
+ while (cursor < state.text.length && !/\s/.test(state.text[cursor] ?? ""))
55
+ cursor++;
56
+ return { ...state, cursor };
57
+ }
58
+ export function moveLineStart(state) {
59
+ return { ...state, cursor: locate(state).lineStart };
60
+ }
61
+ export function moveLineEnd(state) {
62
+ return { ...state, cursor: locate(state).lineEnd };
63
+ }
64
+ export function moveUp(state) {
65
+ const { column, lineStart } = locate(state);
66
+ if (lineStart === 0)
67
+ return { ...state, cursor: 0 };
68
+ const previousLineEnd = lineStart - 1;
69
+ const previousLineStart = state.text.lastIndexOf("\n", previousLineEnd - 1) + 1;
70
+ return { ...state, cursor: previousLineStart + Math.min(column, previousLineEnd - previousLineStart) };
71
+ }
72
+ export function moveDown(state) {
73
+ const { column, lineEnd } = locate(state);
74
+ if (lineEnd >= state.text.length)
75
+ return { ...state, cursor: state.text.length };
76
+ const nextLineStart = lineEnd + 1;
77
+ const nextLineNewline = state.text.indexOf("\n", nextLineStart);
78
+ const nextLineEnd = nextLineNewline === -1 ? state.text.length : nextLineNewline;
79
+ return { ...state, cursor: nextLineStart + Math.min(column, nextLineEnd - nextLineStart) };
80
+ }
81
+ export function killLine(state) {
82
+ const { lineEnd } = locate(state);
83
+ return { text: state.text.slice(0, state.cursor) + state.text.slice(lineEnd), cursor: state.cursor };
84
+ }
85
+ export function getCurrentSlashToken(state) {
86
+ const beforeCursor = state.text.slice(0, state.cursor);
87
+ const line = beforeCursor.slice(beforeCursor.lastIndexOf("\n") + 1);
88
+ if (!line.startsWith("/") || /\s/.test(line))
89
+ return null;
90
+ return line;
91
+ }
92
+ function locate(state) {
93
+ const before = state.text.slice(0, state.cursor);
94
+ const lineStart = before.lastIndexOf("\n") + 1;
95
+ const after = state.text.slice(state.cursor);
96
+ const nextNewline = after.indexOf("\n");
97
+ return {
98
+ column: state.cursor - lineStart,
99
+ lineStart,
100
+ lineEnd: nextNewline === -1 ? state.text.length : state.cursor + nextNewline
101
+ };
102
+ }
@@ -0,0 +1,29 @@
1
+ export function buildSlashCommands(agents) {
2
+ return [
3
+ { kind: "new", name: "new", label: "/new", description: `Create a session: /new [${agents.join("|")}] <task>`, insertText: "/new " },
4
+ { kind: "history", name: "history", label: "/history", description: "Browse discovered JSONL history sessions" },
5
+ { kind: "resume", name: "resume", label: "/resume", description: "Restore a JSONL history session: /resume <id-prefix>", insertText: "/resume " },
6
+ { kind: "review", name: "review", label: "/review", description: "Preview selected tmux session pane", insertText: "/review" },
7
+ { kind: "send", name: "send", label: "/send", description: "Send input to a session: /send <id> <text>", insertText: "/send " },
8
+ { kind: "select", name: "select", label: "/select", description: "Select a session by id/title", insertText: "/select " },
9
+ { kind: "open", name: "open", label: "/open", description: "Attach to selected tmux session" },
10
+ { kind: "kill", name: "kill", label: "/kill", description: "Stop selected tmux session" },
11
+ { kind: "status", name: "status", label: "/status", description: "Override status: /status <id> <status>", insertText: "/status " },
12
+ { kind: "refresh", name: "refresh", label: "/refresh", description: "Reload tmux/session state" },
13
+ { kind: "help", name: "help", label: "/help", description: "Show available slash commands" },
14
+ { kind: "quit", name: "quit", label: "/quit", description: "Exit workboard" }
15
+ ];
16
+ }
17
+ export function filterSlashCommands(items, token) {
18
+ if (!token.startsWith("/"))
19
+ return [];
20
+ const query = token.slice(1).toLowerCase();
21
+ if (!query)
22
+ return items;
23
+ return items.filter((item) => item.label.slice(1).toLowerCase().includes(query) || item.description.toLowerCase().includes(query));
24
+ }
25
+ export function isExactSlashCommand(input, item) {
26
+ if (!item)
27
+ return false;
28
+ return input.trim() === item.label;
29
+ }