ccdock 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/package.json +48 -0
- package/src/agent/hook.ts +134 -0
- package/src/config/config.ts +58 -0
- package/src/main.ts +65 -0
- package/src/sidebar.ts +556 -0
- package/src/tui/ansi.ts +148 -0
- package/src/tui/input.ts +116 -0
- package/src/tui/render.ts +321 -0
- package/src/tui/wizard.ts +166 -0
- package/src/types.ts +86 -0
- package/src/workspace/editor.ts +32 -0
- package/src/workspace/state.ts +150 -0
- package/src/workspace/window.ts +323 -0
- package/src/worktree/manager.ts +120 -0
- package/src/worktree/scanner.ts +82 -0
package/src/tui/input.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export type KeyAction =
|
|
2
|
+
| { type: "up" }
|
|
3
|
+
| { type: "down" }
|
|
4
|
+
| { type: "enter" }
|
|
5
|
+
| { type: "tab" }
|
|
6
|
+
| { type: "quit" }
|
|
7
|
+
| { type: "new" }
|
|
8
|
+
| { type: "delete" }
|
|
9
|
+
| { type: "compact" }
|
|
10
|
+
| { type: "log" }
|
|
11
|
+
| { type: "realign" }
|
|
12
|
+
| { type: "mouse_click"; row: number; col: number }
|
|
13
|
+
| { type: "unknown" };
|
|
14
|
+
|
|
15
|
+
export type WizardKeyAction =
|
|
16
|
+
| { type: "up" }
|
|
17
|
+
| { type: "down" }
|
|
18
|
+
| { type: "enter" }
|
|
19
|
+
| { type: "escape" }
|
|
20
|
+
| { type: "backspace" }
|
|
21
|
+
| { type: "char"; char: string }
|
|
22
|
+
| { type: "unknown" };
|
|
23
|
+
|
|
24
|
+
export function parseKey(data: Buffer): KeyAction {
|
|
25
|
+
// SGR mouse: \x1b[<button;col;rowM (press) or \x1b[<button;col;rowm (release)
|
|
26
|
+
const s = data.toString();
|
|
27
|
+
const sgrMatch = s.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/);
|
|
28
|
+
if (sgrMatch) {
|
|
29
|
+
const button = Number.parseInt(sgrMatch[1]!, 10);
|
|
30
|
+
const col = Number.parseInt(sgrMatch[2]!, 10);
|
|
31
|
+
const row = Number.parseInt(sgrMatch[3]!, 10);
|
|
32
|
+
const isPress = sgrMatch[4] === "M";
|
|
33
|
+
// button 0 = left click press
|
|
34
|
+
if (isPress && button === 0) {
|
|
35
|
+
return { type: "mouse_click", row, col };
|
|
36
|
+
}
|
|
37
|
+
// Scroll up/down
|
|
38
|
+
if (isPress && button === 64) return { type: "up" };
|
|
39
|
+
if (isPress && button === 65) return { type: "down" };
|
|
40
|
+
return { type: "unknown" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Escape sequences for arrow keys
|
|
44
|
+
if (s === "\x1b[A" || s === "k") return { type: "up" };
|
|
45
|
+
if (s === "\x1b[B" || s === "j") return { type: "down" };
|
|
46
|
+
|
|
47
|
+
// Enter
|
|
48
|
+
if (s === "\r" || s === "\n") return { type: "enter" };
|
|
49
|
+
|
|
50
|
+
// Tab
|
|
51
|
+
if (s === "\t") return { type: "tab" };
|
|
52
|
+
|
|
53
|
+
// Quit: q or Ctrl+C
|
|
54
|
+
if (s === "q" || s === "\x03") return { type: "quit" };
|
|
55
|
+
|
|
56
|
+
// New session: n
|
|
57
|
+
if (s === "n") return { type: "new" };
|
|
58
|
+
|
|
59
|
+
// Delete: d or x
|
|
60
|
+
if (s === "d" || s === "x") return { type: "delete" };
|
|
61
|
+
|
|
62
|
+
// Compact mode: c
|
|
63
|
+
if (s === "c") return { type: "compact" };
|
|
64
|
+
|
|
65
|
+
// Activity log: l
|
|
66
|
+
if (s === "l") return { type: "log" };
|
|
67
|
+
|
|
68
|
+
// Realign VS Code windows: r
|
|
69
|
+
if (s === "r") return { type: "realign" };
|
|
70
|
+
|
|
71
|
+
return { type: "unknown" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function parseKeyWizard(data: Buffer): WizardKeyAction {
|
|
75
|
+
const s = data.toString();
|
|
76
|
+
|
|
77
|
+
// Escape sequences for arrow keys
|
|
78
|
+
if (s === "\x1b[A") return { type: "up" };
|
|
79
|
+
if (s === "\x1b[B") return { type: "down" };
|
|
80
|
+
|
|
81
|
+
// Enter
|
|
82
|
+
if (s === "\r" || s === "\n") return { type: "enter" };
|
|
83
|
+
|
|
84
|
+
// Escape or Ctrl+C
|
|
85
|
+
if (s === "\x1b" || s === "\x03") return { type: "escape" };
|
|
86
|
+
|
|
87
|
+
// Backspace
|
|
88
|
+
if (s === "\x7f" || s === "\x08") return { type: "backspace" };
|
|
89
|
+
|
|
90
|
+
// Printable characters
|
|
91
|
+
if (s.length === 1 && s.charCodeAt(0) >= 32 && s.charCodeAt(0) <= 126) {
|
|
92
|
+
return { type: "char", char: s };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { type: "unknown" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// SGR extended mouse mode: supports coordinates > 223
|
|
99
|
+
const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
|
|
100
|
+
const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1006l";
|
|
101
|
+
|
|
102
|
+
export function enableRawMode(): void {
|
|
103
|
+
if (process.stdin.isTTY) {
|
|
104
|
+
process.stdin.setRawMode(true);
|
|
105
|
+
process.stdin.resume();
|
|
106
|
+
process.stdout.write(MOUSE_ENABLE);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function disableRawMode(): void {
|
|
111
|
+
if (process.stdin.isTTY) {
|
|
112
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
113
|
+
process.stdin.setRawMode(false);
|
|
114
|
+
process.stdin.pause();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { DeleteConfirm, SidebarState, WorkspaceSession } from "../types.ts";
|
|
2
|
+
|
|
3
|
+
const SPINNER_FRAMES = [
|
|
4
|
+
"\u280b",
|
|
5
|
+
"\u2819",
|
|
6
|
+
"\u2839",
|
|
7
|
+
"\u2838",
|
|
8
|
+
"\u283c",
|
|
9
|
+
"\u2834",
|
|
10
|
+
"\u2826",
|
|
11
|
+
"\u2827",
|
|
12
|
+
"\u2807",
|
|
13
|
+
"\u280f",
|
|
14
|
+
];
|
|
15
|
+
import {
|
|
16
|
+
BOLD,
|
|
17
|
+
BOX,
|
|
18
|
+
CLEAR_SCREEN,
|
|
19
|
+
COLORS,
|
|
20
|
+
CURSOR_HOME,
|
|
21
|
+
DIM,
|
|
22
|
+
RESET,
|
|
23
|
+
clearLine,
|
|
24
|
+
moveCursor,
|
|
25
|
+
shortenHome,
|
|
26
|
+
statusColor,
|
|
27
|
+
statusIcon,
|
|
28
|
+
truncate,
|
|
29
|
+
visibleLength,
|
|
30
|
+
} from "./ansi.ts";
|
|
31
|
+
|
|
32
|
+
function padRight(str: string, len: number): string {
|
|
33
|
+
const visible = visibleLength(str);
|
|
34
|
+
if (visible >= len) return str;
|
|
35
|
+
return str + " ".repeat(len - visible);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderDeleteConfirm(deleteConfirm: DeleteConfirm, cols: number): string[] {
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
const width = Math.max(cols - 6, 16);
|
|
41
|
+
|
|
42
|
+
lines.push(` ${COLORS.border}${BOX.horizontal.repeat(width)}${RESET}`);
|
|
43
|
+
lines.push(` ${BOLD}${COLORS.error} Delete session?${RESET}`);
|
|
44
|
+
lines.push("");
|
|
45
|
+
|
|
46
|
+
const options = ["Remove session only", "Remove session + worktree"];
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < options.length; i++) {
|
|
49
|
+
const opt = options[i];
|
|
50
|
+
if (!opt) continue;
|
|
51
|
+
const isSelected = i === deleteConfirm.selectedIndex;
|
|
52
|
+
const marker = isSelected ? `${COLORS.highlight}\u25b6${RESET}` : " ";
|
|
53
|
+
const label = isSelected
|
|
54
|
+
? `${BOLD}${COLORS.title}${opt}${RESET}`
|
|
55
|
+
: `${COLORS.subtitle}${opt}${RESET}`;
|
|
56
|
+
lines.push(` ${marker} ${label}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
lines.push("");
|
|
60
|
+
lines.push(` ${COLORS.muted}Enter: confirm | Esc: cancel${RESET}`);
|
|
61
|
+
lines.push(` ${COLORS.border}${BOX.horizontal.repeat(width)}${RESET}`);
|
|
62
|
+
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function renderCard(
|
|
67
|
+
session: WorkspaceSession,
|
|
68
|
+
isSelected: boolean,
|
|
69
|
+
cols: number,
|
|
70
|
+
animFrame: number,
|
|
71
|
+
compact: boolean,
|
|
72
|
+
deleteConfirm: DeleteConfirm | null,
|
|
73
|
+
sessionIndex: number,
|
|
74
|
+
): string[] {
|
|
75
|
+
const lines: string[] = [];
|
|
76
|
+
const width = Math.max(cols - 2, 20);
|
|
77
|
+
const bg = isSelected ? COLORS.bgSelected : "";
|
|
78
|
+
const resetBg = isSelected ? RESET : "";
|
|
79
|
+
|
|
80
|
+
// Colors based on editor state
|
|
81
|
+
// Focused: white border, normal title
|
|
82
|
+
// Open: normal border + green ● dot
|
|
83
|
+
// Closed: dim everything
|
|
84
|
+
const editorState = session.editorState;
|
|
85
|
+
const isFocused = editorState === "focused";
|
|
86
|
+
const isClosed = editorState === "closed";
|
|
87
|
+
const isLaunching = editorState === "launching";
|
|
88
|
+
const borderColor = isFocused
|
|
89
|
+
? COLORS.borderFocused
|
|
90
|
+
: isClosed
|
|
91
|
+
? COLORS.borderClosed
|
|
92
|
+
: COLORS.border;
|
|
93
|
+
const titleColor = isClosed ? COLORS.editorClosed : COLORS.title;
|
|
94
|
+
const detailColor = isClosed ? COLORS.editorClosed : COLORS.subtitle;
|
|
95
|
+
const dimAll = isClosed ? DIM : "";
|
|
96
|
+
|
|
97
|
+
// Status dot: spinning for launching, green solid for open/focused, none for closed
|
|
98
|
+
let openDot = "";
|
|
99
|
+
if (isLaunching) {
|
|
100
|
+
const frame = SPINNER_FRAMES[animFrame % SPINNER_FRAMES.length]!;
|
|
101
|
+
openDot = `${COLORS.waiting}${frame}${RESET} `;
|
|
102
|
+
} else if (!isClosed) {
|
|
103
|
+
openDot = `${COLORS.running}\u25cf${RESET} `;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Card border top
|
|
107
|
+
const topBorder = `${dimAll}${borderColor}${BOX.topLeft}${BOX.horizontal.repeat(width - 2)}${BOX.topRight}${RESET}`;
|
|
108
|
+
lines.push(topBorder);
|
|
109
|
+
|
|
110
|
+
// Title line: #N + dot (if open) + icon + repo:branch
|
|
111
|
+
const icon = "\uf418";
|
|
112
|
+
const sessionNum = `${COLORS.muted}#${sessionIndex + 1}${RESET} `;
|
|
113
|
+
const titleText = `${titleColor}${icon} ${session.repoName}:${session.branch}${RESET}`;
|
|
114
|
+
const title = `${sessionNum}${openDot}${titleText}`;
|
|
115
|
+
const titleTruncated = truncate(title, width - 4);
|
|
116
|
+
const titleLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(titleTruncated, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
|
|
117
|
+
lines.push(titleLine);
|
|
118
|
+
|
|
119
|
+
if (!compact) {
|
|
120
|
+
// Path line
|
|
121
|
+
const shortPath = shortenHome(session.worktreePath);
|
|
122
|
+
const pathTruncated = truncate(shortPath, width - 4);
|
|
123
|
+
const pathColor = editorState === "closed" ? COLORS.editorClosed : COLORS.subtitle;
|
|
124
|
+
const pathLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${pathColor}${padRight(pathTruncated, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
|
|
125
|
+
lines.push(pathLine);
|
|
126
|
+
|
|
127
|
+
// Agent status lines
|
|
128
|
+
if (session.agents.length === 0) {
|
|
129
|
+
const noAgent = `${DIM}no agents${RESET}`;
|
|
130
|
+
const agentLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(noAgent, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
|
|
131
|
+
lines.push(agentLine);
|
|
132
|
+
} else {
|
|
133
|
+
for (const agent of session.agents) {
|
|
134
|
+
const sIcon = statusIcon(agent.status, animFrame);
|
|
135
|
+
const sColor = statusColor(agent.status);
|
|
136
|
+
const statusText = `${sColor}${sIcon} ${agent.status}${RESET}`;
|
|
137
|
+
const agentInfo = `${statusText} ${detailColor}${agent.agentType}${RESET}`;
|
|
138
|
+
const agentLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(agentInfo, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
|
|
139
|
+
lines.push(agentLine);
|
|
140
|
+
|
|
141
|
+
// Show latest tool activity
|
|
142
|
+
if (agent.toolName) {
|
|
143
|
+
const detail = agent.toolDetail
|
|
144
|
+
? `${detailColor}${agent.toolName}${RESET} ${detailColor}${agent.toolDetail}${RESET}`
|
|
145
|
+
: `${detailColor}${agent.toolName}${RESET}`;
|
|
146
|
+
const detailTruncated = truncate(` ${detail}`, width - 4);
|
|
147
|
+
const detailLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(detailTruncated, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
|
|
148
|
+
lines.push(detailLine);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
// Compact: just show agent status inline
|
|
154
|
+
const agentSummary =
|
|
155
|
+
session.agents.length > 0
|
|
156
|
+
? session.agents
|
|
157
|
+
.map((a) => `${statusColor(a.status)}${statusIcon(a.status, animFrame)}${RESET}`)
|
|
158
|
+
.join(" ")
|
|
159
|
+
: `${DIM}no agents${RESET}`;
|
|
160
|
+
const compactLine = `${dimAll}${borderColor}${BOX.vertical}${RESET} ${bg}${padRight(agentSummary, width - 4)}${resetBg}${RESET} ${dimAll}${borderColor}${BOX.vertical}${RESET}`;
|
|
161
|
+
lines.push(compactLine);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Delete confirmation inline
|
|
165
|
+
if (isSelected && deleteConfirm && deleteConfirm.sessionId === session.id) {
|
|
166
|
+
const confirmLines = renderDeleteConfirm(deleteConfirm, cols);
|
|
167
|
+
for (const cl of confirmLines) {
|
|
168
|
+
const confirmLine = `${borderColor}${BOX.vertical}${RESET} ${padRight(cl, width - 4)} ${borderColor}${BOX.vertical}${RESET}`;
|
|
169
|
+
lines.push(confirmLine);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Card border bottom
|
|
174
|
+
const bottomBorder = `${dimAll}${borderColor}${BOX.bottomLeft}${BOX.horizontal.repeat(width - 2)}${BOX.bottomRight}${RESET}`;
|
|
175
|
+
lines.push(bottomBorder);
|
|
176
|
+
|
|
177
|
+
return lines;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderActivityLog(state: SidebarState, maxLines: number): string[] {
|
|
181
|
+
const lines: string[] = [];
|
|
182
|
+
const width = Math.max(state.cols - 2, 20);
|
|
183
|
+
|
|
184
|
+
lines.push(`${COLORS.border}${BOX.horizontal.repeat(width)}${RESET}`);
|
|
185
|
+
lines.push(`${BOLD}${COLORS.highlight} Activity Log${RESET}`);
|
|
186
|
+
|
|
187
|
+
const entries = state.activityLog.slice(-maxLines);
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
const sessionTag =
|
|
190
|
+
entry.sessionIndex >= 0 ? `${COLORS.accent}#${entry.sessionIndex + 1}${RESET} ` : "";
|
|
191
|
+
const detail = entry.toolDetail ? ` ${COLORS.muted}${entry.toolDetail}${RESET}` : "";
|
|
192
|
+
const line = `${COLORS.muted}${entry.time}${RESET} ${sessionTag}${COLORS.highlight}${entry.tool}${RESET}${detail}`;
|
|
193
|
+
lines.push(` ${truncate(line, width - 2)}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (entries.length === 0) {
|
|
197
|
+
lines.push(`${DIM} (no activity yet)${RESET}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function renderFooter(cols: number): string[] {
|
|
204
|
+
const line1 = [
|
|
205
|
+
`${BOLD}j/k${RESET} nav`,
|
|
206
|
+
`${BOLD}Enter${RESET} focus`,
|
|
207
|
+
`${BOLD}n${RESET} new`,
|
|
208
|
+
`${BOLD}d${RESET} del`,
|
|
209
|
+
`${BOLD}r${RESET} realign`,
|
|
210
|
+
].join(`${COLORS.muted} | ${RESET}`);
|
|
211
|
+
const line2 = [`${BOLD}c${RESET} compact`, `${BOLD}l${RESET} log`, `${BOLD}q${RESET} quit`].join(
|
|
212
|
+
`${COLORS.muted} | ${RESET}`,
|
|
213
|
+
);
|
|
214
|
+
return [
|
|
215
|
+
`${COLORS.muted}${BOX.horizontal.repeat(Math.max(cols - 2, 1))}${RESET}`,
|
|
216
|
+
` ${truncate(line1, cols - 2)}`,
|
|
217
|
+
` ${truncate(line2, cols - 2)}`,
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderQuitConfirm(selectedIndex: number, cols: number): string[] {
|
|
222
|
+
const width = Math.max(cols - 4, 20);
|
|
223
|
+
const lines: string[] = [];
|
|
224
|
+
lines.push("");
|
|
225
|
+
lines.push(`${BOLD}${COLORS.highlight} Quit ccdock?${RESET}`);
|
|
226
|
+
lines.push("");
|
|
227
|
+
|
|
228
|
+
const options = [
|
|
229
|
+
"Quit sidebar only (keep VS Code open)",
|
|
230
|
+
"Quit sidebar and close all VS Code windows",
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < options.length; i++) {
|
|
234
|
+
const isSelected = i === selectedIndex;
|
|
235
|
+
const marker = isSelected ? `${COLORS.highlight}\u25b6${RESET}` : " ";
|
|
236
|
+
const label = isSelected
|
|
237
|
+
? `${BOLD}${COLORS.title}${options[i]}${RESET}`
|
|
238
|
+
: `${COLORS.subtitle}${options[i]}${RESET}`;
|
|
239
|
+
lines.push(truncate(` ${marker} ${label}`, width));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
lines.push("");
|
|
243
|
+
lines.push(`${COLORS.muted} Enter: confirm | Esc: cancel${RESET}`);
|
|
244
|
+
return lines;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function renderSidebar(state: SidebarState): string {
|
|
248
|
+
const output: string[] = [];
|
|
249
|
+
output.push(CLEAR_SCREEN + CURSOR_HOME);
|
|
250
|
+
|
|
251
|
+
// Quit confirmation takes over the screen
|
|
252
|
+
if (state.quitConfirm) {
|
|
253
|
+
output.push(...renderQuitConfirm(state.quitConfirm.selectedIndex, state.cols));
|
|
254
|
+
return output.join("\n");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Header
|
|
258
|
+
const header = `${BOLD}${COLORS.highlight} ccdock${RESET} ${COLORS.muted}(${state.sessions.length} sessions)${RESET}`;
|
|
259
|
+
output.push(header);
|
|
260
|
+
output.push("");
|
|
261
|
+
|
|
262
|
+
// Calculate available space
|
|
263
|
+
const footerHeight = 3;
|
|
264
|
+
const headerHeight = 2;
|
|
265
|
+
const logHeight = state.showActivityLog ? Math.min(8, state.activityLog.length + 2) : 0;
|
|
266
|
+
const availableForCards = state.rows - headerHeight - footerHeight - logHeight;
|
|
267
|
+
|
|
268
|
+
// Render session cards
|
|
269
|
+
let linesUsed = 0;
|
|
270
|
+
state.cardRowRanges = [];
|
|
271
|
+
const cardStartOffset = output.length; // rows before cards (header)
|
|
272
|
+
for (let i = 0; i < state.sessions.length; i++) {
|
|
273
|
+
const session = state.sessions[i];
|
|
274
|
+
if (!session) continue;
|
|
275
|
+
const isSelected = i === state.selectedIndex;
|
|
276
|
+
const cardLines = renderCard(
|
|
277
|
+
session,
|
|
278
|
+
isSelected,
|
|
279
|
+
state.cols,
|
|
280
|
+
state.animationFrame,
|
|
281
|
+
state.compactMode,
|
|
282
|
+
state.deleteConfirm,
|
|
283
|
+
i,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (linesUsed + cardLines.length > availableForCards) break;
|
|
287
|
+
const startRow = cardStartOffset + linesUsed + 1; // 1-based row
|
|
288
|
+
state.cardRowRanges.push({
|
|
289
|
+
sessionIndex: i,
|
|
290
|
+
startRow,
|
|
291
|
+
endRow: startRow + cardLines.length - 1,
|
|
292
|
+
});
|
|
293
|
+
output.push(...cardLines);
|
|
294
|
+
linesUsed += cardLines.length;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (state.sessions.length === 0) {
|
|
298
|
+
output.push("");
|
|
299
|
+
output.push(`${DIM} No active sessions.${RESET}`);
|
|
300
|
+
output.push(`${DIM} Press 'n' to create a new session.${RESET}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Fill remaining space
|
|
304
|
+
const currentLines = output.length;
|
|
305
|
+
const targetLine = state.rows - footerHeight - logHeight;
|
|
306
|
+
for (let i = currentLines; i < targetLine; i++) {
|
|
307
|
+
output.push("");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Activity log
|
|
311
|
+
if (state.showActivityLog) {
|
|
312
|
+
const logLines = renderActivityLog(state, 5);
|
|
313
|
+
output.push(...logLines);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Footer
|
|
317
|
+
const footerLines = renderFooter(state.cols);
|
|
318
|
+
output.push(...footerLines);
|
|
319
|
+
|
|
320
|
+
return output.join("\n");
|
|
321
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { RepoInfo, WizardState, WorktreeEntry } from "../types.ts";
|
|
2
|
+
import { BOLD, BOX, CLEAR_SCREEN, COLORS, CURSOR_HOME, DIM, RESET, truncate } from "./ansi.ts";
|
|
3
|
+
|
|
4
|
+
function renderRepoList(
|
|
5
|
+
repos: RepoInfo[],
|
|
6
|
+
selectedIndex: number,
|
|
7
|
+
filter: string,
|
|
8
|
+
cols: number,
|
|
9
|
+
): string[] {
|
|
10
|
+
const lines: string[] = [];
|
|
11
|
+
const width = Math.max(cols - 4, 20);
|
|
12
|
+
|
|
13
|
+
lines.push(`${BOLD}${COLORS.highlight} Select Repository${RESET}`);
|
|
14
|
+
lines.push("");
|
|
15
|
+
|
|
16
|
+
if (filter) {
|
|
17
|
+
lines.push(`${COLORS.muted} Filter: ${RESET}${filter}`);
|
|
18
|
+
lines.push("");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const filtered = repos.filter((r) => r.name.toLowerCase().includes(filter.toLowerCase()));
|
|
22
|
+
|
|
23
|
+
if (filtered.length === 0) {
|
|
24
|
+
lines.push(`${DIM} No repos matching "${filter}"${RESET}`);
|
|
25
|
+
} else {
|
|
26
|
+
for (let i = 0; i < filtered.length; i++) {
|
|
27
|
+
const repo = filtered[i];
|
|
28
|
+
if (!repo) continue;
|
|
29
|
+
const isSelected = i === selectedIndex;
|
|
30
|
+
const marker = isSelected ? `${COLORS.highlight}\u25b6${RESET}` : " ";
|
|
31
|
+
const name = isSelected
|
|
32
|
+
? `${BOLD}${COLORS.title}${repo.name}${RESET}`
|
|
33
|
+
: `${COLORS.subtitle}${repo.name}${RESET}`;
|
|
34
|
+
const branch = `${COLORS.muted}(${repo.defaultBranch})${RESET}`;
|
|
35
|
+
const line = ` ${marker} ${name} ${branch}`;
|
|
36
|
+
lines.push(truncate(line, width));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push(`${COLORS.muted} j/k: navigate | Enter: select | Esc: cancel${RESET}`);
|
|
42
|
+
lines.push(`${COLORS.muted} Type to filter repos${RESET}`);
|
|
43
|
+
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderModeSelect(repo: RepoInfo, selectedIndex: number, cols: number): string[] {
|
|
48
|
+
const lines: string[] = [];
|
|
49
|
+
const width = Math.max(cols - 4, 20);
|
|
50
|
+
|
|
51
|
+
lines.push(`${BOLD}${COLORS.highlight} Create Session: ${repo.name}${RESET}`);
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push(`${COLORS.muted} Select mode:${RESET}`);
|
|
54
|
+
lines.push("");
|
|
55
|
+
|
|
56
|
+
const modes = [
|
|
57
|
+
{ label: "Create new worktree (git wt)", desc: "Create a new feature branch worktree" },
|
|
58
|
+
{ label: "Use existing worktree", desc: "Select from existing worktrees" },
|
|
59
|
+
{ label: "Open repository root", desc: "Open the main repository directory" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
for (let i = 0; i < modes.length; i++) {
|
|
63
|
+
const mode = modes[i];
|
|
64
|
+
if (!mode) continue;
|
|
65
|
+
const isSelected = i === selectedIndex;
|
|
66
|
+
const marker = isSelected ? `${COLORS.highlight}\u25b6${RESET}` : " ";
|
|
67
|
+
const label = isSelected
|
|
68
|
+
? `${BOLD}${COLORS.title}${mode.label}${RESET}`
|
|
69
|
+
: `${COLORS.subtitle}${mode.label}${RESET}`;
|
|
70
|
+
const desc = `${COLORS.muted}${mode.desc}${RESET}`;
|
|
71
|
+
lines.push(truncate(` ${marker} ${label}`, width));
|
|
72
|
+
lines.push(truncate(` ${desc}`, width));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push("");
|
|
76
|
+
lines.push(`${COLORS.muted} j/k: navigate | Enter: select | Esc: back${RESET}`);
|
|
77
|
+
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function renderWorktreeList(
|
|
82
|
+
repo: RepoInfo,
|
|
83
|
+
worktrees: WorktreeEntry[],
|
|
84
|
+
selectedIndex: number,
|
|
85
|
+
cols: number,
|
|
86
|
+
): string[] {
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
const width = Math.max(cols - 4, 20);
|
|
89
|
+
|
|
90
|
+
lines.push(`${BOLD}${COLORS.highlight} Select Worktree: ${repo.name}${RESET}`);
|
|
91
|
+
lines.push("");
|
|
92
|
+
|
|
93
|
+
if (worktrees.length === 0) {
|
|
94
|
+
lines.push(`${DIM} No existing worktrees found.${RESET}`);
|
|
95
|
+
} else {
|
|
96
|
+
for (let i = 0; i < worktrees.length; i++) {
|
|
97
|
+
const wt = worktrees[i];
|
|
98
|
+
if (!wt) continue;
|
|
99
|
+
const isSelected = i === selectedIndex;
|
|
100
|
+
const marker = isSelected ? `${COLORS.highlight}\u25b6${RESET}` : " ";
|
|
101
|
+
const branchLabel = isSelected
|
|
102
|
+
? `${BOLD}${COLORS.title}${wt.branch || "(detached)"}${RESET}`
|
|
103
|
+
: `${COLORS.subtitle}${wt.branch || "(detached)"}${RESET}`;
|
|
104
|
+
const pathLabel = `${COLORS.muted}${wt.path}${RESET}`;
|
|
105
|
+
lines.push(truncate(` ${marker} ${branchLabel}`, width));
|
|
106
|
+
lines.push(truncate(` ${pathLabel}`, width));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
lines.push("");
|
|
111
|
+
lines.push(`${COLORS.muted} j/k: navigate | Enter: select | Esc: back${RESET}`);
|
|
112
|
+
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderBranchInput(repo: RepoInfo, branchName: string, cols: number): string[] {
|
|
117
|
+
const lines: string[] = [];
|
|
118
|
+
const width = Math.max(cols - 4, 20);
|
|
119
|
+
|
|
120
|
+
lines.push(`${BOLD}${COLORS.highlight} Create Session: ${repo.name}${RESET}`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push(`${COLORS.muted} Enter branch name:${RESET}`);
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(` ${COLORS.border}${BOX.horizontal.repeat(width - 6)}${RESET}`);
|
|
125
|
+
lines.push(` ${BOLD}${branchName}${RESET}\u2588`);
|
|
126
|
+
lines.push(` ${COLORS.border}${BOX.horizontal.repeat(width - 6)}${RESET}`);
|
|
127
|
+
lines.push("");
|
|
128
|
+
|
|
129
|
+
if (branchName) {
|
|
130
|
+
const preview = `${repo.name}--${branchName.replace(/\//g, "-")}`;
|
|
131
|
+
lines.push(`${COLORS.muted} Worktree dir: ${preview}${RESET}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push("");
|
|
135
|
+
lines.push(`${COLORS.muted} Enter: create | Esc: back${RESET}`);
|
|
136
|
+
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function renderWizard(wizard: WizardState, cols: number): string {
|
|
141
|
+
if (!wizard) return "";
|
|
142
|
+
|
|
143
|
+
const output: string[] = [];
|
|
144
|
+
output.push(CLEAR_SCREEN + CURSOR_HOME);
|
|
145
|
+
|
|
146
|
+
let content: string[];
|
|
147
|
+
|
|
148
|
+
switch (wizard.step) {
|
|
149
|
+
case "select-repo":
|
|
150
|
+
content = renderRepoList(wizard.repos, wizard.selectedIndex, wizard.filter, cols);
|
|
151
|
+
break;
|
|
152
|
+
case "select-mode":
|
|
153
|
+
content = renderModeSelect(wizard.repo, wizard.selectedIndex, cols);
|
|
154
|
+
break;
|
|
155
|
+
case "select-worktree":
|
|
156
|
+
content = renderWorktreeList(wizard.repo, wizard.worktrees, wizard.selectedIndex, cols);
|
|
157
|
+
break;
|
|
158
|
+
case "enter-branch":
|
|
159
|
+
content = renderBranchInput(wizard.repo, wizard.branchName, cols);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
output.push(...content);
|
|
164
|
+
|
|
165
|
+
return output.join("\n");
|
|
166
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export type AgentType = "claude-code" | "codex";
|
|
2
|
+
export type AgentStatus = "running" | "waiting" | "idle" | "error" | "unknown";
|
|
3
|
+
|
|
4
|
+
export interface AgentState {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
agentType: AgentType;
|
|
7
|
+
status: AgentStatus;
|
|
8
|
+
prompt: string;
|
|
9
|
+
toolName: string;
|
|
10
|
+
toolDetail: string; // human-readable detail from tool_input
|
|
11
|
+
cwd: string;
|
|
12
|
+
updatedAt: number; // Date.now()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type EditorState = "focused" | "open" | "closed" | "launching";
|
|
16
|
+
|
|
17
|
+
export interface WorkspaceSession {
|
|
18
|
+
id: string; // unique session ID (uuid or short hash)
|
|
19
|
+
sessionName: string; // display name
|
|
20
|
+
worktreePath: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
repoName: string; // extracted from path
|
|
23
|
+
agents: AgentState[]; // populated from state files
|
|
24
|
+
editorState: EditorState; // VS Code window state
|
|
25
|
+
createdAt: number;
|
|
26
|
+
lastActiveAt: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RepoInfo {
|
|
30
|
+
name: string;
|
|
31
|
+
path: string;
|
|
32
|
+
defaultBranch: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorktreeEntry {
|
|
36
|
+
path: string;
|
|
37
|
+
branch: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DeleteConfirm {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
worktreePath: string;
|
|
43
|
+
selectedIndex: number; // 0 = session only, 1 = session + worktree
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SidebarState {
|
|
47
|
+
sessions: WorkspaceSession[];
|
|
48
|
+
selectedIndex: number;
|
|
49
|
+
rows: number;
|
|
50
|
+
cols: number;
|
|
51
|
+
animationFrame: number;
|
|
52
|
+
compactMode: boolean;
|
|
53
|
+
showActivityLog: boolean;
|
|
54
|
+
cardRowRanges: Array<{ sessionIndex: number; startRow: number; endRow: number }>;
|
|
55
|
+
activityLog: Array<{
|
|
56
|
+
time: string;
|
|
57
|
+
sessionId: string;
|
|
58
|
+
sessionIndex: number;
|
|
59
|
+
agent: string;
|
|
60
|
+
tool: string;
|
|
61
|
+
toolDetail: string;
|
|
62
|
+
}>;
|
|
63
|
+
wizard: WizardState;
|
|
64
|
+
deleteConfirm: DeleteConfirm | null;
|
|
65
|
+
quitConfirm: { selectedIndex: number } | null; // 0=quit only, 1=quit+close editors
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Wizard steps for creating new sessions
|
|
69
|
+
export type WizardStep =
|
|
70
|
+
| { step: "select-repo"; repos: RepoInfo[]; selectedIndex: number; filter: string }
|
|
71
|
+
| { step: "select-mode"; repo: RepoInfo; selectedIndex: number; repos: RepoInfo[] }
|
|
72
|
+
| {
|
|
73
|
+
step: "select-worktree";
|
|
74
|
+
repo: RepoInfo;
|
|
75
|
+
worktrees: WorktreeEntry[];
|
|
76
|
+
selectedIndex: number;
|
|
77
|
+
repos: RepoInfo[];
|
|
78
|
+
}
|
|
79
|
+
| { step: "enter-branch"; repo: RepoInfo; branchName: string; repos: RepoInfo[] };
|
|
80
|
+
|
|
81
|
+
export type WizardState = WizardStep | null;
|
|
82
|
+
|
|
83
|
+
export interface HubConfig {
|
|
84
|
+
workspace_dirs: string[];
|
|
85
|
+
editor: "code" | "cursor";
|
|
86
|
+
}
|