@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.21
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/package.json +5 -5
- package/src/App.plan-review.test.tsx +61 -0
- package/src/App.rendering.test.tsx +6 -2
- package/src/App.review-panel.test.tsx +97 -0
- package/src/App.tsx +95 -22
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +14 -1
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/config.test.ts +51 -1
- package/src/config.ts +57 -22
- package/src/keymap.test.ts +44 -4
- package/src/keymap.ts +30 -5
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/session-controller.ts +17 -1
package/src/config.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Layered TOML config: built-in defaults → user config → trusted repo
|
|
3
3
|
* config → env. Sections: [keys] action = "combo" (every action rebindable),
|
|
4
|
-
* [theme] per-token overrides, [ui] auto_close + editor
|
|
5
|
-
* notes-vault
|
|
4
|
+
* [theme] per-token overrides, [ui] auto_close + editor + the review-panel
|
|
5
|
+
* layout (review_width + review_state), [integrations.obsidian] notes-vault
|
|
6
|
+
* export.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -12,6 +13,7 @@ import { OBSIDIAN_DEFAULTS, type ObsidianConfig } from "@cueloop/integration-obs
|
|
|
12
13
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
13
14
|
import { dirname } from "node:path";
|
|
14
15
|
import { DARK, type Theme } from "./theme";
|
|
16
|
+
import { REVIEW_DEFAULT_WIDTH, clampWidth, type ReviewPanelMode } from "./review-panel";
|
|
15
17
|
|
|
16
18
|
export interface KeymapConfig {
|
|
17
19
|
[action: string]: string | string[];
|
|
@@ -27,7 +29,11 @@ export type AutoClose = "off" | number;
|
|
|
27
29
|
export interface CueloopConfig {
|
|
28
30
|
keys: Record<string, string[]>;
|
|
29
31
|
theme: Theme;
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* ui.reviewState / ui.reviewWidth are CLIENT VIEW STATE: the review panel's
|
|
34
|
+
* collapse mode and expanded-rail width, persisted so they survive restarts.
|
|
35
|
+
*/
|
|
36
|
+
ui: { autoClose: AutoClose; editor?: string; reviewState: ReviewPanelMode; reviewWidth: number };
|
|
31
37
|
integrations: IntegrationsConfig;
|
|
32
38
|
}
|
|
33
39
|
|
|
@@ -48,6 +54,9 @@ export const DEFAULT_KEYS: Record<string, string[]> = {
|
|
|
48
54
|
submit: ["return", "enter"],
|
|
49
55
|
quit: ["q"],
|
|
50
56
|
walk: ["w"],
|
|
57
|
+
review_cycle: ["b"],
|
|
58
|
+
review_wider: ["]"],
|
|
59
|
+
review_narrower: ["["],
|
|
51
60
|
};
|
|
52
61
|
|
|
53
62
|
function parseToml(text: string): Record<string, unknown> {
|
|
@@ -68,12 +77,20 @@ function layer(base: CueloopConfig, raw: Record<string, unknown>): CueloopConfig
|
|
|
68
77
|
out.keys[action] = Array.isArray(combo) ? combo : [combo];
|
|
69
78
|
}
|
|
70
79
|
}
|
|
71
|
-
const ui = raw["ui"] as
|
|
80
|
+
const ui = raw["ui"] as
|
|
81
|
+
| { auto_close?: unknown; editor?: unknown; review_width?: unknown; review_state?: unknown }
|
|
82
|
+
| undefined;
|
|
72
83
|
if (ui && ui.auto_close !== undefined) {
|
|
73
84
|
if (ui.auto_close === "off") out.ui.autoClose = "off";
|
|
74
85
|
else if (typeof ui.auto_close === "number" && ui.auto_close >= 0) out.ui.autoClose = ui.auto_close;
|
|
75
86
|
}
|
|
76
87
|
if (ui && typeof ui.editor === "string" && ui.editor.trim()) out.ui.editor = ui.editor.trim();
|
|
88
|
+
if (ui && typeof ui.review_width === "number" && Number.isFinite(ui.review_width)) {
|
|
89
|
+
out.ui.reviewWidth = clampWidth(ui.review_width);
|
|
90
|
+
}
|
|
91
|
+
if (ui && (ui.review_state === "expanded" || ui.review_state === "compact" || ui.review_state === "hidden")) {
|
|
92
|
+
out.ui.reviewState = ui.review_state;
|
|
93
|
+
}
|
|
77
94
|
const theme = raw["theme"] as Partial<Record<keyof Theme, string>> | undefined;
|
|
78
95
|
if (theme) {
|
|
79
96
|
for (const [token, value] of Object.entries(theme)) {
|
|
@@ -101,13 +118,10 @@ export function loadConfig(options: { repoRoot?: string; userConfigPath?: string
|
|
|
101
118
|
let config: CueloopConfig = {
|
|
102
119
|
keys: { ...DEFAULT_KEYS },
|
|
103
120
|
theme: { ...DARK },
|
|
104
|
-
ui: { autoClose: "off" },
|
|
121
|
+
ui: { autoClose: "off", reviewState: "expanded", reviewWidth: REVIEW_DEFAULT_WIDTH },
|
|
105
122
|
integrations: { obsidian: { ...OBSIDIAN_DEFAULTS } },
|
|
106
123
|
};
|
|
107
|
-
const userPath =
|
|
108
|
-
options.userConfigPath ??
|
|
109
|
-
process.env.CUELOOP_CONFIG ??
|
|
110
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml");
|
|
124
|
+
const userPath = userConfigPathFrom(options.userConfigPath);
|
|
111
125
|
for (const path of [userPath, options.repoRoot ? join(options.repoRoot, ".cueloop", "config.toml") : undefined]) {
|
|
112
126
|
if (!path || !existsSync(path)) continue;
|
|
113
127
|
try {
|
|
@@ -128,26 +142,47 @@ export function actionFor(keys: Record<string, string[]>, name: string, shift: b
|
|
|
128
142
|
return undefined;
|
|
129
143
|
}
|
|
130
144
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
* surgery on the one key we own: replace an existing `auto_close`, or append
|
|
134
|
-
* an `[ui]` section - a full TOML writer is not worth its weight here.
|
|
135
|
-
*/
|
|
136
|
-
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
137
|
-
const path =
|
|
145
|
+
function userConfigPathFrom(userConfigPath?: string): string {
|
|
146
|
+
return (
|
|
138
147
|
userConfigPath ??
|
|
139
148
|
process.env.CUELOOP_CONFIG ??
|
|
140
|
-
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
141
|
-
|
|
149
|
+
join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "cueloop", "config.toml")
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Persist one `[ui]` key into the user config file. Line-level TOML surgery on
|
|
155
|
+
* the keys we own: replace the existing assignment, or drop it under an `[ui]`
|
|
156
|
+
* section (appending the section when absent) - a full TOML writer is not worth
|
|
157
|
+
* its weight here.
|
|
158
|
+
*/
|
|
159
|
+
function persistUiSetting(key: string, rendered: string, userConfigPath?: string): void {
|
|
160
|
+
const path = userConfigPathFrom(userConfigPath);
|
|
142
161
|
let text = "";
|
|
143
162
|
if (existsSync(path)) text = readFileSync(path, "utf8");
|
|
144
|
-
|
|
145
|
-
|
|
163
|
+
const assignment = new RegExp(`^(\\s*)${key}\\s*=.*$`, "m");
|
|
164
|
+
if (assignment.test(text)) {
|
|
165
|
+
text = text.replace(assignment, `$1${key} = ${rendered}`);
|
|
146
166
|
} else if (/^\[ui\]/m.test(text)) {
|
|
147
|
-
text = text.replace(/^\[ui\]\s*$/m, `[ui]\
|
|
167
|
+
text = text.replace(/^\[ui\]\s*$/m, `[ui]\n${key} = ${rendered}`);
|
|
148
168
|
} else {
|
|
149
|
-
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\
|
|
169
|
+
text = text.trimEnd() + (text.trim() ? "\n\n" : "") + `[ui]\n${key} = ${rendered}\n`;
|
|
150
170
|
}
|
|
151
171
|
mkdirSync(dirname(path), { recursive: true });
|
|
152
172
|
writeFileSync(path, text);
|
|
153
173
|
}
|
|
174
|
+
|
|
175
|
+
/** Persist the auto-close choice (`[ui] auto_close`) into the user config. */
|
|
176
|
+
export function persistAutoClose(value: AutoClose, userConfigPath?: string): void {
|
|
177
|
+
persistUiSetting("auto_close", value === "off" ? '"off"' : String(value), userConfigPath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Persist the expanded-rail width (`[ui] review_width`) into the user config. */
|
|
181
|
+
export function persistReviewWidth(width: number, userConfigPath?: string): void {
|
|
182
|
+
persistUiSetting("review_width", String(clampWidth(width)), userConfigPath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Persist the review-panel collapse mode (`[ui] review_state`) into the config. */
|
|
186
|
+
export function persistReviewState(state: ReviewPanelMode, userConfigPath?: string): void {
|
|
187
|
+
persistUiSetting("review_state", `"${state}"`, userConfigPath);
|
|
188
|
+
}
|
package/src/keymap.test.ts
CHANGED
|
@@ -25,8 +25,8 @@ function state(patch: Partial<KeyState> = {}): KeyState {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
function key(name: string, shift = false): { name: string; shift: boolean } {
|
|
29
|
-
return { name, shift };
|
|
28
|
+
function key(name: string, shift = false, meta = false): { name: string; shift: boolean; meta: boolean } {
|
|
29
|
+
return { name, shift, meta };
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
describe("compose overlay", () => {
|
|
@@ -45,6 +45,15 @@ describe("compose overlay", () => {
|
|
|
45
45
|
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
46
46
|
});
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
// ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
|
|
50
|
+
// grammar must not save on them; only a bare ⏎ saves.
|
|
51
|
+
test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
|
|
52
|
+
expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
test("shift+return does not save - the textarea inserts a newline", () => {
|
|
55
|
+
expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
|
|
56
|
+
});
|
|
48
57
|
});
|
|
49
58
|
|
|
50
59
|
describe("submit overlay", () => {
|
|
@@ -53,8 +62,8 @@ describe("submit overlay", () => {
|
|
|
53
62
|
["escape", [{ type: "closeOverlay" }]],
|
|
54
63
|
["return", [{ type: "submitVerdict" }]],
|
|
55
64
|
["enter", [{ type: "submitVerdict" }]],
|
|
56
|
-
["left", [{ type: "cycleVerdict",
|
|
57
|
-
["right", [{ type: "cycleVerdict",
|
|
65
|
+
["left", [{ type: "cycleVerdict", direction: -1 }]],
|
|
66
|
+
["right", [{ type: "cycleVerdict", direction: 1 }]],
|
|
58
67
|
["j", []],
|
|
59
68
|
["q", []],
|
|
60
69
|
];
|
|
@@ -265,6 +274,37 @@ describe("guided walk", () => {
|
|
|
265
274
|
});
|
|
266
275
|
});
|
|
267
276
|
|
|
277
|
+
describe("review panel controls", () => {
|
|
278
|
+
const table: [string, Intent[]][] = [
|
|
279
|
+
["b", [{ type: "cycleReviewPanel" }]],
|
|
280
|
+
["]", [{ type: "resizeReviewPanel", direction: 1 }]],
|
|
281
|
+
["[", [{ type: "resizeReviewPanel", direction: -1 }]],
|
|
282
|
+
];
|
|
283
|
+
for (const view of ["plan", "diff"] as const) {
|
|
284
|
+
for (const [name, expected] of table) {
|
|
285
|
+
test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
286
|
+
expect(reduceKey(state({ view }), key(name))).toEqual(expected);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
292
|
+
const observer = state({ readOnly: true });
|
|
293
|
+
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
294
|
+
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("span mode still owns b as a span verb, not a panel cycle", () => {
|
|
298
|
+
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("the walk overlay keeps the brackets for stepping", () => {
|
|
302
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
303
|
+
expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
|
|
304
|
+
expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
268
308
|
describe("duplicated-branch collapse", () => {
|
|
269
309
|
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
270
310
|
for (const patch of [
|
package/src/keymap.ts
CHANGED
|
@@ -32,14 +32,18 @@ export type Intent =
|
|
|
32
32
|
| { type: "closeOverlay" }
|
|
33
33
|
| { type: "saveCompose" }
|
|
34
34
|
| { type: "submitVerdict" }
|
|
35
|
-
| { type: "cycleVerdict";
|
|
35
|
+
| { type: "cycleVerdict"; direction: -1 | 1 }
|
|
36
36
|
| { type: "finishReview" }
|
|
37
37
|
| { type: "optInAutoClose" }
|
|
38
|
-
| { type: "dismissCompletion" }
|
|
38
|
+
| { type: "dismissCompletion" }
|
|
39
|
+
| { type: "cycleReviewPanel" }
|
|
40
|
+
| { type: "resizeReviewPanel"; direction: -1 | 1 };
|
|
39
41
|
|
|
40
42
|
export interface KeyInput {
|
|
41
43
|
name: string;
|
|
42
44
|
shift: boolean;
|
|
45
|
+
/** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
|
|
46
|
+
meta?: boolean;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
export interface KeyState {
|
|
@@ -83,10 +87,17 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
83
87
|
if (state.overlay === "compose" || state.overlay === "submit") {
|
|
84
88
|
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
85
89
|
if (name === "return" || name === "enter") {
|
|
86
|
-
|
|
90
|
+
// In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
|
|
91
|
+
// focused textarea owns that; only a bare ⏎ saves. The submit overlay
|
|
92
|
+
// keeps its plain ⏎ submit.
|
|
93
|
+
if (state.overlay === "compose") {
|
|
94
|
+
if (key.shift || key.meta) return [];
|
|
95
|
+
return [{ type: "saveCompose" }];
|
|
96
|
+
}
|
|
97
|
+
return [{ type: "submitVerdict" }];
|
|
87
98
|
}
|
|
88
99
|
if (state.overlay === "submit" && (name === "left" || name === "right")) {
|
|
89
|
-
return [{ type: "cycleVerdict",
|
|
100
|
+
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
90
101
|
}
|
|
91
102
|
return [];
|
|
92
103
|
}
|
|
@@ -117,11 +128,25 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
117
128
|
if (state.readOnly && mutating) return status("observer - read-only");
|
|
118
129
|
|
|
119
130
|
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
120
|
-
|
|
131
|
+
// span mode owns its single-letter keys (b slides the span back) before the
|
|
132
|
+
// review-panel controls claim them
|
|
121
133
|
if (state.spanMode) return spanGrammar(name);
|
|
134
|
+
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
135
|
+
// are view state, so the read-only gate above lets them through
|
|
136
|
+
const reviewPanel = reviewPanelGrammar(action);
|
|
137
|
+
if (reviewPanel) return reviewPanel;
|
|
138
|
+
if (state.view === "diff") return diffGrammar(state, action);
|
|
122
139
|
return planGrammar(state, action, name);
|
|
123
140
|
}
|
|
124
141
|
|
|
142
|
+
/** The review-panel controls: cycle the mode, widen and narrow the rail. */
|
|
143
|
+
function reviewPanelGrammar(action: string | undefined): Intent[] | null {
|
|
144
|
+
if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
|
|
145
|
+
if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
|
|
146
|
+
if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
125
150
|
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
126
151
|
if (!state.hasInboxItems) return [];
|
|
127
152
|
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
REVIEW_DEFAULT_WIDTH,
|
|
4
|
+
REVIEW_MAX_WIDTH,
|
|
5
|
+
REVIEW_MIN_WIDTH,
|
|
6
|
+
REVIEW_PLAN_MIN_WIDTH,
|
|
7
|
+
REVIEW_RESIZE_STEP,
|
|
8
|
+
clampWidth,
|
|
9
|
+
cycleReviewPanelMode,
|
|
10
|
+
maxWidthForTerminal,
|
|
11
|
+
resolveReviewWidth,
|
|
12
|
+
reviewRowsToDivider,
|
|
13
|
+
toggleReviewPanelMode,
|
|
14
|
+
widthFromMouseColumn,
|
|
15
|
+
} from "./review-panel";
|
|
16
|
+
|
|
17
|
+
describe("clampWidth", () => {
|
|
18
|
+
test("keeps a width inside the bounds untouched", () => {
|
|
19
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("clamps below the minimum and above the maximum", () => {
|
|
23
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - 10)).toBe(REVIEW_MIN_WIDTH);
|
|
24
|
+
expect(clampWidth(REVIEW_MAX_WIDTH + 10)).toBe(REVIEW_MAX_WIDTH);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rounds a fractional column to a whole one", () => {
|
|
28
|
+
expect(clampWidth(30.7)).toBe(31);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("honors explicit bounds", () => {
|
|
32
|
+
expect(clampWidth(100, 10, 20)).toBe(20);
|
|
33
|
+
expect(clampWidth(5, 10, 20)).toBe(10);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("cycleReviewPanelMode", () => {
|
|
38
|
+
test("cycles expanded -> compact -> hidden -> expanded", () => {
|
|
39
|
+
expect(cycleReviewPanelMode("expanded")).toBe("compact");
|
|
40
|
+
expect(cycleReviewPanelMode("compact")).toBe("hidden");
|
|
41
|
+
expect(cycleReviewPanelMode("hidden")).toBe("expanded");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("toggleReviewPanelMode", () => {
|
|
46
|
+
test("the chevron toggles only between expanded and compact, never hidden", () => {
|
|
47
|
+
expect(toggleReviewPanelMode("expanded")).toBe("compact");
|
|
48
|
+
expect(toggleReviewPanelMode("compact")).toBe("expanded");
|
|
49
|
+
// a hidden panel has no chevron; a stray toggle still opens it
|
|
50
|
+
expect(toggleReviewPanelMode("hidden")).toBe("expanded");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("widthFromMouseColumn", () => {
|
|
55
|
+
test("measures the rail from the mouse column to the right edge", () => {
|
|
56
|
+
// divider dragged to column 80 of a 120-wide terminal -> a 40-col rail
|
|
57
|
+
expect(widthFromMouseColumn(80, 120)).toBe(40);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("clamps the dragged width to the bounds", () => {
|
|
61
|
+
expect(widthFromMouseColumn(0, 120)).toBe(REVIEW_MAX_WIDTH);
|
|
62
|
+
expect(widthFromMouseColumn(119, 120)).toBe(REVIEW_MIN_WIDTH);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("cannot drag the rail wide enough to starve the plan on a narrow terminal", () => {
|
|
66
|
+
// dragging to the far left edge of a 60-col terminal still leaves the plan its minimum
|
|
67
|
+
expect(widthFromMouseColumn(0, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the resize step widens and narrows within the clamp", () => {
|
|
71
|
+
expect(clampWidth(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP)).toBe(REVIEW_DEFAULT_WIDTH + REVIEW_RESIZE_STEP);
|
|
72
|
+
expect(clampWidth(REVIEW_MIN_WIDTH - REVIEW_RESIZE_STEP)).toBe(REVIEW_MIN_WIDTH);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("maxWidthForTerminal", () => {
|
|
77
|
+
test("allows the full rail on a wide terminal", () => {
|
|
78
|
+
expect(maxWidthForTerminal(120)).toBe(REVIEW_MAX_WIDTH);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("reserves the plan its minimum on a narrow terminal", () => {
|
|
82
|
+
// 60-col terminal: 30 for the plan and 1 for the divider leaves 29 for the rail
|
|
83
|
+
expect(maxWidthForTerminal(60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("never drops below the rail minimum, however narrow the terminal", () => {
|
|
87
|
+
expect(maxWidthForTerminal(40)).toBe(REVIEW_MIN_WIDTH);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("resolveReviewWidth", () => {
|
|
92
|
+
test("leaves a width that already fits untouched", () => {
|
|
93
|
+
expect(resolveReviewWidth(REVIEW_DEFAULT_WIDTH, 120)).toBe(REVIEW_DEFAULT_WIDTH);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("shrinks a wide persisted width so the plan keeps its room", () => {
|
|
97
|
+
// a saved 50-col rail on a 60-col terminal would leave the plan 9 cols; clamp it back
|
|
98
|
+
expect(resolveReviewWidth(REVIEW_MAX_WIDTH, 60)).toBe(60 - REVIEW_PLAN_MIN_WIDTH - 1);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("reviewRowsToDivider", () => {
|
|
103
|
+
test("builds one vertical glyph per row", () => {
|
|
104
|
+
expect(reviewRowsToDivider(3)).toBe("│\n│\n│");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("never collapses to an empty column", () => {
|
|
108
|
+
expect(reviewRowsToDivider(0)).toBe("│");
|
|
109
|
+
expect(reviewRowsToDivider(-4)).toBe("│");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry and state for the resizable review panel. The panel shows the
|
|
3
|
+
* review rail in one of three modes and the expanded rail resizes within a
|
|
4
|
+
* clamp; App owns the React state and the mouse wiring, this module owns the
|
|
5
|
+
* arithmetic so the layout math stays unit-tested and out of the JSX. Bounds
|
|
6
|
+
* and the compact width carry over verbatim from the approved prototype.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Expanded (full rail), compact (count + dots strip), or hidden (nothing). */
|
|
10
|
+
export type ReviewPanelMode = "expanded" | "compact" | "hidden";
|
|
11
|
+
|
|
12
|
+
/** Expanded-rail width bounds, in terminal columns. */
|
|
13
|
+
export const REVIEW_MIN_WIDTH = 24;
|
|
14
|
+
export const REVIEW_MAX_WIDTH = 50;
|
|
15
|
+
export const REVIEW_DEFAULT_WIDTH = 34;
|
|
16
|
+
/**
|
|
17
|
+
* Columns the plan/diff column keeps for itself: the expanded rail yields past
|
|
18
|
+
* this so a narrow terminal (or a wide persisted width) can never crowd the
|
|
19
|
+
* plan out to nothing - the whole reason the panel is resizable. On terminals
|
|
20
|
+
* wide enough for the full 50-col rail this bound never binds.
|
|
21
|
+
*/
|
|
22
|
+
export const REVIEW_PLAN_MIN_WIDTH = 30;
|
|
23
|
+
/** The compact strip is a fixed narrow column: count on top, one dot per card. */
|
|
24
|
+
export const REVIEW_COMPACT_WIDTH = 6;
|
|
25
|
+
/** Keyboard resize step, in columns, for the widen/narrow keys. */
|
|
26
|
+
export const REVIEW_RESIZE_STEP = 2;
|
|
27
|
+
|
|
28
|
+
/** Round a desired width to a whole column and clamp it to the bounds. */
|
|
29
|
+
export function clampWidth(desiredWidth: number, min = REVIEW_MIN_WIDTH, max = REVIEW_MAX_WIDTH): number {
|
|
30
|
+
return Math.max(min, Math.min(max, Math.round(desiredWidth)));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The widest the expanded rail may be on a given terminal, reserving the plan
|
|
35
|
+
* its minimum and one column for the divider. Never drops below REVIEW_MIN_WIDTH
|
|
36
|
+
* (an extremely narrow terminal is the auto-collapse case, handled elsewhere).
|
|
37
|
+
*/
|
|
38
|
+
export function maxWidthForTerminal(terminalWidth: number): number {
|
|
39
|
+
return Math.max(REVIEW_MIN_WIDTH, Math.min(REVIEW_MAX_WIDTH, terminalWidth - REVIEW_PLAN_MIN_WIDTH - 1));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Clamp a desired rail width to the bounds AND the terminal, so the plan keeps its room. */
|
|
43
|
+
export function resolveReviewWidth(desiredWidth: number, terminalWidth: number): number {
|
|
44
|
+
return clampWidth(desiredWidth, REVIEW_MIN_WIDTH, maxWidthForTerminal(terminalWidth));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The keybinding cycle: expanded -> compact -> hidden -> expanded. */
|
|
48
|
+
export function cycleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
|
|
49
|
+
return mode === "expanded" ? "compact" : mode === "compact" ? "hidden" : "expanded";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The clickable chevron toggles only between expanded and compact - hidden is
|
|
54
|
+
* reachable by keybinding alone, so it never leaves a leftover tab behind. A
|
|
55
|
+
* toggle from hidden still opens the panel rather than doing nothing.
|
|
56
|
+
*/
|
|
57
|
+
export function toggleReviewPanelMode(mode: ReviewPanelMode): ReviewPanelMode {
|
|
58
|
+
return mode === "expanded" ? "compact" : "expanded";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rail width from an absolute mouse column while dragging the divider: the rail
|
|
63
|
+
* spans from the mouse column to the terminal's right edge, then clamps.
|
|
64
|
+
*/
|
|
65
|
+
export function widthFromMouseColumn(mouseColumn: number, terminalWidth: number): number {
|
|
66
|
+
return resolveReviewWidth(terminalWidth - mouseColumn, terminalWidth);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The divider is a single-column stack of vertical glyphs (a text block, not a
|
|
71
|
+
* bordered box - a one-column box with a border renders a full box). One glyph
|
|
72
|
+
* per row, at least one so the column never collapses.
|
|
73
|
+
*/
|
|
74
|
+
export function reviewRowsToDivider(rows: number): string {
|
|
75
|
+
return Array.from({ length: Math.max(1, rows) }, () => "│").join("\n");
|
|
76
|
+
}
|
|
@@ -28,7 +28,8 @@ import { diffRowAnchor, diffRows, type DiffRow } from "./view-diff";
|
|
|
28
28
|
import { firstUnviewedIndex, walkFiles, type WalkFile } from "./walk";
|
|
29
29
|
import { editInEditor } from "./editor";
|
|
30
30
|
import { focusHerdrPane } from "./herdr";
|
|
31
|
-
import { persistAutoClose, type AutoClose, type CueloopConfig } from "./config";
|
|
31
|
+
import { persistAutoClose, persistReviewState, persistReviewWidth, type AutoClose, type CueloopConfig } from "./config";
|
|
32
|
+
import type { ReviewPanelMode } from "./review-panel";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Post-submit lifecycle (a review pane should hand you back to the agent,
|
|
@@ -119,6 +120,12 @@ export interface ReviewController {
|
|
|
119
120
|
dismissCompletion(): void;
|
|
120
121
|
/** From the completion prompt: persist auto-close and start the countdown. */
|
|
121
122
|
optInAutoClose(): void;
|
|
123
|
+
/**
|
|
124
|
+
* Persist the review-panel layout (client view state) to the user config so
|
|
125
|
+
* the collapse mode and rail width survive a restart. A read-only config dir
|
|
126
|
+
* never blocks the review.
|
|
127
|
+
*/
|
|
128
|
+
saveReviewPanel(layout: { mode?: ReviewPanelMode; width?: number }): void;
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
export function createReviewController(options: ReviewControllerOptions): ReviewController {
|
|
@@ -493,4 +500,13 @@ class Controller implements ReviewController {
|
|
|
493
500
|
}
|
|
494
501
|
this.autoClose = DEFAULT_AUTO_CLOSE;
|
|
495
502
|
}
|
|
503
|
+
|
|
504
|
+
saveReviewPanel(layout: { mode?: ReviewPanelMode; width?: number }): void {
|
|
505
|
+
try {
|
|
506
|
+
if (layout.mode !== undefined) persistReviewState(layout.mode);
|
|
507
|
+
if (layout.width !== undefined) persistReviewWidth(layout.width);
|
|
508
|
+
} catch {
|
|
509
|
+
// a read-only config dir must never block collapsing or resizing the rail
|
|
510
|
+
}
|
|
511
|
+
}
|
|
496
512
|
}
|