tuiboard 0.8.3 → 0.9.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/.tuiboard/config.example.yaml +9 -0
- package/CHANGELOG.md +102 -0
- package/README.md +111 -1
- package/bin/tuiboard.ts +12 -0
- package/package.json +5 -1
- package/src/app.tsx +70 -17
- package/src/boards/config-writer.ts +191 -0
- package/src/boards/create.ts +57 -0
- package/src/boards/scan.ts +95 -0
- package/src/boards/suggest.ts +34 -0
- package/src/cli/args.ts +2 -2
- package/src/cli/board.ts +151 -0
- package/src/cli/summary.ts +284 -0
- package/src/cli/task.ts +235 -0
- package/src/config/loader.ts +60 -13
- package/src/input/handleKey.ts +72 -1
- package/src/io/watcher.ts +11 -0
- package/src/parser/markdown.ts +10 -0
- package/src/store/index.ts +379 -12
- package/src/ui/BoardView.tsx +8 -6
- package/src/ui/Chrome.tsx +65 -9
- package/src/ui/Modal.tsx +133 -2
- package/src/ui/PlannerPanel.tsx +1 -1
- package/src/ui/pane-ring.ts +73 -0
- package/src/views/BoardOnly.tsx +3 -2
- package/src/views/Dashboard.tsx +36 -8
- package/tsconfig.json +27 -0
- package/src/cli/args.test.ts +0 -40
- package/src/scripts/agents-check.ts +0 -24
- package/src/scripts/parse-check.ts +0 -124
- package/src/scripts/roundtrip-check.ts +0 -79
- package/src/store/agents.test.ts +0 -181
- package/src/store/index.test.ts +0 -224
- package/src/store/parsers.test.ts +0 -64
- package/src/store/timeline.test.ts +0 -332
- package/src/ui/board-scroll.test.ts +0 -63
- package/src/ui/glyphs.test.ts +0 -31
package/src/store/index.ts
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { readFileSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
22
24
|
import { createMemo } from "solid-js";
|
|
23
25
|
import { createStore, produce } from "solid-js/store";
|
|
24
26
|
|
|
25
|
-
import type
|
|
27
|
+
import { isHiddenColumn, type Config } from "~/config/loader";
|
|
26
28
|
import {
|
|
27
29
|
createBoardWatcher,
|
|
28
30
|
type BoardWatcher,
|
|
@@ -39,7 +41,12 @@ import {
|
|
|
39
41
|
type WritableCalendar,
|
|
40
42
|
} from "./calendar";
|
|
41
43
|
import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
|
|
44
|
+
import { addBoardToConfig } from "~/boards/config-writer";
|
|
45
|
+
import { createBoardFile } from "~/boards/create";
|
|
46
|
+
import { scanDirectory, type BoardCandidate } from "~/boards/scan";
|
|
47
|
+
import { suggestBoardsDir } from "~/boards/suggest";
|
|
42
48
|
import { isTask, parseBoard } from "~/parser/markdown";
|
|
49
|
+
import { buildRing, ringPosition, samePane, stepRing, type Pane } from "~/ui/pane-ring";
|
|
43
50
|
import { serializeBoard } from "~/parser/serialize";
|
|
44
51
|
import type {
|
|
45
52
|
Board,
|
|
@@ -80,6 +87,7 @@ export type ModalKind =
|
|
|
80
87
|
| { kind: "event-edit" }
|
|
81
88
|
| { kind: "confirm-delete-event" }
|
|
82
89
|
| { kind: "search" }
|
|
90
|
+
| { kind: "board-new" }
|
|
83
91
|
| { kind: "help" };
|
|
84
92
|
|
|
85
93
|
/**
|
|
@@ -87,6 +95,33 @@ export type ModalKind =
|
|
|
87
95
|
* title+time `<input>`; step 2 is the calendar picker, navigated via handleKey
|
|
88
96
|
* (no input focused). Lives in UI state so the key handler can drive it.
|
|
89
97
|
*/
|
|
98
|
+
/**
|
|
99
|
+
* State of the board-creation wizard, alive only while
|
|
100
|
+
* `modal.kind === "board-new"`. Kept beside the modal rather than inside it,
|
|
101
|
+
* the way `eventPicker` is: the modal says *what* is open, this says where the
|
|
102
|
+
* user has got to.
|
|
103
|
+
*/
|
|
104
|
+
export interface BoardNew {
|
|
105
|
+
step: "mode" | "name" | "columns" | "dir" | "pick";
|
|
106
|
+
/** Chosen path through the wizard. */
|
|
107
|
+
mode?: "create" | "adopt";
|
|
108
|
+
/** Selection index — the mode list on step 1, the candidate list on "pick". */
|
|
109
|
+
sel: number;
|
|
110
|
+
name: string;
|
|
111
|
+
/** Comma-separated, as typed. */
|
|
112
|
+
columns: string;
|
|
113
|
+
dir: string;
|
|
114
|
+
candidates: BoardCandidate[];
|
|
115
|
+
/** Indexes of the candidates ticked for adoption. */
|
|
116
|
+
ticked: number[];
|
|
117
|
+
/**
|
|
118
|
+
* True on first run, when there is no board behind the modal to go back to.
|
|
119
|
+
* Escape does not dismiss it.
|
|
120
|
+
*/
|
|
121
|
+
mandatory: boolean;
|
|
122
|
+
error?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
90
125
|
export interface EventPicker {
|
|
91
126
|
step: 1 | 2;
|
|
92
127
|
/** Selection index into `cals` (step 2). */
|
|
@@ -150,6 +185,16 @@ export interface UIState {
|
|
|
150
185
|
* Toggled with `z`.
|
|
151
186
|
*/
|
|
152
187
|
zoomed: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* The terminal is too narrow to host more than one zone. Set by the
|
|
190
|
+
* responsive layer, never by the user.
|
|
191
|
+
*
|
|
192
|
+
* Distinct from `zoomed`, which is the user's own choice, because the two
|
|
193
|
+
* must not overwrite each other: widening the window has to restore the
|
|
194
|
+
* layout without also cancelling a `z` the user pressed on purpose. What the
|
|
195
|
+
* renderer reads is the OR of the two — see `singlePane`.
|
|
196
|
+
*/
|
|
197
|
+
narrow: boolean;
|
|
153
198
|
/**
|
|
154
199
|
* Grab mode: when true, h/l moves the cursor task between adjacent
|
|
155
200
|
* columns instead of just moving the cursor. Toggled with `g`. Exit
|
|
@@ -201,6 +246,8 @@ export interface UIState {
|
|
|
201
246
|
modal?: ModalKind;
|
|
202
247
|
/** Two-step new-event modal state (set only while `modal.kind === "event"`). */
|
|
203
248
|
eventPicker?: EventPicker;
|
|
249
|
+
/** Board-creation wizard state (set only while `modal.kind === "board-new"`). */
|
|
250
|
+
boardNew?: BoardNew;
|
|
204
251
|
}
|
|
205
252
|
|
|
206
253
|
export interface UndoEntry {
|
|
@@ -275,6 +322,7 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
275
322
|
col: 0,
|
|
276
323
|
row: 0,
|
|
277
324
|
zoomed: false,
|
|
325
|
+
narrow: false,
|
|
278
326
|
grabbing: false,
|
|
279
327
|
armMode: false,
|
|
280
328
|
agendaOffset: 0,
|
|
@@ -803,6 +851,174 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
803
851
|
|
|
804
852
|
// ─── Cursor / UI ─────────────────────────────────────────────────────────
|
|
805
853
|
|
|
854
|
+
// ─── Board creation wizard ───────────────────────────────────────────────
|
|
855
|
+
// The screen is thin on purpose: everything that touches the disk lives in
|
|
856
|
+
// src/boards/, so the same steps are reachable from `tuiboard board add`.
|
|
857
|
+
|
|
858
|
+
function openBoardNew(mandatory = false): void {
|
|
859
|
+
setState("ui", "boardNew", {
|
|
860
|
+
step: "mode",
|
|
861
|
+
sel: 0,
|
|
862
|
+
name: "",
|
|
863
|
+
columns: "Todo, Doing, Done",
|
|
864
|
+
dir: suggestBoardsDir(config),
|
|
865
|
+
candidates: [],
|
|
866
|
+
ticked: [],
|
|
867
|
+
mandatory,
|
|
868
|
+
});
|
|
869
|
+
openModal({ kind: "board-new" });
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function patchBoardNew(patch: Partial<BoardNew>): void {
|
|
873
|
+
setState("ui", "boardNew", produce((b: BoardNew | undefined) => {
|
|
874
|
+
if (b) Object.assign(b, patch);
|
|
875
|
+
}));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function closeBoardNew(): void {
|
|
879
|
+
// A mandatory wizard has nothing behind it: there is no board to return to.
|
|
880
|
+
if (state.ui.boardNew?.mandatory) return;
|
|
881
|
+
setState("ui", "boardNew", undefined);
|
|
882
|
+
closeModal();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Step 1: which way through. */
|
|
886
|
+
function boardNewChooseMode(mode: "create" | "adopt"): void {
|
|
887
|
+
patchBoardNew({ mode, step: mode === "create" ? "name" : "dir", error: undefined });
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function boardNewMove(delta: number): void {
|
|
891
|
+
const b = state.ui.boardNew;
|
|
892
|
+
if (!b) return;
|
|
893
|
+
const len = b.step === "pick" ? b.candidates.length : 2;
|
|
894
|
+
if (len === 0) return;
|
|
895
|
+
patchBoardNew({ sel: Math.max(0, Math.min(len - 1, b.sel + delta)) });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Step "pick": Space ticks a candidate. Already-configured ones are inert. */
|
|
899
|
+
function boardNewToggle(): void {
|
|
900
|
+
const b = state.ui.boardNew;
|
|
901
|
+
if (!b || b.step !== "pick") return;
|
|
902
|
+
const c = b.candidates[b.sel];
|
|
903
|
+
if (!c || c.alreadyInConfig) return;
|
|
904
|
+
const ticked = b.ticked.includes(b.sel)
|
|
905
|
+
? b.ticked.filter((i) => i !== b.sel)
|
|
906
|
+
: [...b.ticked, b.sel];
|
|
907
|
+
patchBoardNew({ ticked });
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Text submitted by the modal's <input>, per step. */
|
|
911
|
+
function boardNewSubmitText(text: string): void {
|
|
912
|
+
const b = state.ui.boardNew;
|
|
913
|
+
if (!b) return;
|
|
914
|
+
const value = text.trim();
|
|
915
|
+
|
|
916
|
+
if (b.step === "name") {
|
|
917
|
+
if (!value) return patchBoardNew({ error: "the board needs a name" });
|
|
918
|
+
return patchBoardNew({ name: value, step: "columns", error: undefined });
|
|
919
|
+
}
|
|
920
|
+
if (b.step === "columns") {
|
|
921
|
+
return commitCreate(b.name, value || b.columns);
|
|
922
|
+
}
|
|
923
|
+
if (b.step === "dir") {
|
|
924
|
+
const dir = expandHome(value || b.dir);
|
|
925
|
+
const candidates = scanDirectory(dir, {
|
|
926
|
+
existingPaths: state.boards.map((lb) => lb.board.filepath),
|
|
927
|
+
});
|
|
928
|
+
if (candidates.length === 0) {
|
|
929
|
+
return patchBoardNew({ dir, error: `no board files in ${dir}` });
|
|
930
|
+
}
|
|
931
|
+
return patchBoardNew({ dir, candidates, step: "pick", sel: 0, ticked: [], error: undefined });
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/** Create the file, register it, open it — in that order. */
|
|
936
|
+
function commitCreate(name: string, columnsText: string): void {
|
|
937
|
+
const b = state.ui.boardNew;
|
|
938
|
+
if (!b) return;
|
|
939
|
+
const columns = columnsText.split(",").map((c) => c.trim()).filter(Boolean);
|
|
940
|
+
const path = join(b.dir, `${name}.md`);
|
|
941
|
+
try {
|
|
942
|
+
createBoardFile(path, { columns });
|
|
943
|
+
} catch (e) {
|
|
944
|
+
return patchBoardNew({ error: (e as Error).message });
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
addBoardToConfig({ path, name });
|
|
948
|
+
} catch (e) {
|
|
949
|
+
// The file is on disk and is not lost: say exactly that, and stay put.
|
|
950
|
+
return patchBoardNew({
|
|
951
|
+
error: `created ${path}, but not registered: ${(e as Error).message}`,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
finishBoardNew([{ path, name }]);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/** Adopt every ticked candidate; one failure does not stop the others. */
|
|
958
|
+
function boardNewConfirmPick(): void {
|
|
959
|
+
const b = state.ui.boardNew;
|
|
960
|
+
if (!b || b.step !== "pick") return;
|
|
961
|
+
const chosen = (b.ticked.length > 0 ? b.ticked : [b.sel])
|
|
962
|
+
.map((i) => b.candidates[i])
|
|
963
|
+
.filter((c): c is BoardCandidate => !!c && !c.alreadyInConfig);
|
|
964
|
+
if (chosen.length === 0) return patchBoardNew({ error: "nothing to adopt" });
|
|
965
|
+
|
|
966
|
+
const added: Array<{ path: string; name: string }> = [];
|
|
967
|
+
const failed: string[] = [];
|
|
968
|
+
for (const c of chosen) {
|
|
969
|
+
try {
|
|
970
|
+
addBoardToConfig({ path: c.path, name: c.suggestedName });
|
|
971
|
+
added.push({ path: c.path, name: c.suggestedName });
|
|
972
|
+
} catch (e) {
|
|
973
|
+
failed.push(`${c.suggestedName}: ${(e as Error).message}`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if (added.length === 0) return patchBoardNew({ error: failed.join(" · ") });
|
|
977
|
+
finishBoardNew(added, failed);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function finishBoardNew(
|
|
981
|
+
added: Array<{ path: string; name: string }>,
|
|
982
|
+
failed: string[] = [],
|
|
983
|
+
): void {
|
|
984
|
+
const problems = [...failed];
|
|
985
|
+
for (const a of added) {
|
|
986
|
+
const res = addBoard(a.path, a.name);
|
|
987
|
+
if (!res.ok) problems.push(`${a.name}: ${res.error}`);
|
|
988
|
+
}
|
|
989
|
+
setState("ui", "boardNew", undefined);
|
|
990
|
+
closeModal();
|
|
991
|
+
const what = added.length === 1 ? added[0]!.name : `${added.length} boards`;
|
|
992
|
+
if (problems.length > 0) flashBanner("warn", `Added ${what} — ${problems.join(" · ")}`);
|
|
993
|
+
else flashBanner("info", `Added ${what}`);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Adopt a board while tuiboard is running: parse it, append it, start
|
|
998
|
+
* watching it, and move the cursor onto it.
|
|
999
|
+
*
|
|
1000
|
+
* Called after the file and the config entry already exist (that ordering is
|
|
1001
|
+
* what keeps a failure conservative — see the board lifecycle spec), so this
|
|
1002
|
+
* is the last and least destructive step: if it fails, the board is still
|
|
1003
|
+
* registered and appears on the next launch.
|
|
1004
|
+
*/
|
|
1005
|
+
function addBoard(path: string, name?: string): { ok: true } | { ok: false; error: string } {
|
|
1006
|
+
if (state.boards.some((b) => b.board.filepath === path)) {
|
|
1007
|
+
return { ok: false, error: "that board is already open" };
|
|
1008
|
+
}
|
|
1009
|
+
let loaded: LoadedBoard;
|
|
1010
|
+
try {
|
|
1011
|
+
loaded = loadOne(path, name);
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
return { ok: false, error: (e as Error).message };
|
|
1014
|
+
}
|
|
1015
|
+
setState("boards", (boards) => [...boards, loaded]);
|
|
1016
|
+
watcher.watch(path);
|
|
1017
|
+
setState("rev", (r) => r + 1);
|
|
1018
|
+
setActiveBoard(state.boards.length - 1);
|
|
1019
|
+
return { ok: true };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
806
1022
|
function setActiveBoard(idx: number): void {
|
|
807
1023
|
const len = state.boards.length;
|
|
808
1024
|
if (len === 0) return;
|
|
@@ -829,7 +1045,11 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
829
1045
|
function recomputeVisible(): void {
|
|
830
1046
|
const v = computeVisible();
|
|
831
1047
|
setState("ui", "visibleZones", v);
|
|
832
|
-
|
|
1048
|
+
// In single-pane the active zone is drawn because it is active, not
|
|
1049
|
+
// because it "fits", so it is never orphaned — and dragging focus to the
|
|
1050
|
+
// board on every narrowing is exactly the behaviour that made a resize
|
|
1051
|
+
// throw the user off the planner.
|
|
1052
|
+
if (!v[state.ui.activeZone] && !singlePane()) setActiveZone("board");
|
|
833
1053
|
}
|
|
834
1054
|
|
|
835
1055
|
/** Set a zone's desired visibility (user intent). Showing a disabled zone is
|
|
@@ -851,25 +1071,140 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
851
1071
|
|
|
852
1072
|
/** Update the terminal-width fit cache (called by the responsive layout) and
|
|
853
1073
|
* recompute. Never overrides enablement or the user's desired visibility. */
|
|
854
|
-
|
|
1074
|
+
/**
|
|
1075
|
+
* What the terminal's width allows, applied in one shot.
|
|
1076
|
+
*
|
|
1077
|
+
* `narrow` travels with the fits rather than in its own call on purpose:
|
|
1078
|
+
* `recomputeVisible` behaves differently in single-pane, so setting the two
|
|
1079
|
+
* separately makes the result depend on which came first — and the wrong
|
|
1080
|
+
* order silently reintroduces the focus-stealing this mode removes.
|
|
1081
|
+
*/
|
|
1082
|
+
function applyResponsiveFits(
|
|
1083
|
+
fits: Partial<Record<ActiveZone, boolean>>,
|
|
1084
|
+
opts: { narrow?: boolean } = {},
|
|
1085
|
+
): void {
|
|
855
1086
|
lastFits = {
|
|
856
1087
|
board: true,
|
|
857
1088
|
planner: fits.planner !== false,
|
|
858
1089
|
timeline: fits.timeline !== false,
|
|
859
1090
|
agents: fits.agents !== false,
|
|
860
1091
|
};
|
|
1092
|
+
if (opts.narrow !== undefined) setState("ui", "narrow", opts.narrow);
|
|
861
1093
|
recomputeVisible();
|
|
862
1094
|
}
|
|
863
1095
|
|
|
1096
|
+
/**
|
|
1097
|
+
* Next zone, wrapping.
|
|
1098
|
+
*
|
|
1099
|
+
* Reachability is decided by what the user enabled and wants — never by what
|
|
1100
|
+
* fits. Width governs how many zones are drawn at once, not which ones
|
|
1101
|
+
* exist: filtering on `visibleZones` here is what used to leave a narrow
|
|
1102
|
+
* terminal with a single candidate, making this function return immediately
|
|
1103
|
+
* and Shift-Tab do nothing.
|
|
1104
|
+
*/
|
|
864
1105
|
function cycleActiveZone(): void {
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1106
|
+
const reachable = ZONE_ORDER.filter(
|
|
1107
|
+
(z) => enabledZones[z] && (singlePane() ? desiredVisible[z] : state.ui.visibleZones[z]),
|
|
1108
|
+
);
|
|
1109
|
+
if (reachable.length <= 1) return;
|
|
1110
|
+
const currentIdx = reachable.indexOf(state.ui.activeZone);
|
|
1111
|
+
const nextIdx = (currentIdx + 1) % reachable.length;
|
|
1112
|
+
setActiveZone(reachable[nextIdx]!);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* One pane on screen at a time — the state the whole renderer keys off.
|
|
1117
|
+
*
|
|
1118
|
+
* Two roads in: the user pressed `z`, or the terminal is too narrow to hold
|
|
1119
|
+
* two zones side by side. They are deliberately the same state: a narrow
|
|
1120
|
+
* terminal should behave like a deliberate focus, not like a degraded
|
|
1121
|
+
* dashboard.
|
|
1122
|
+
*/
|
|
1123
|
+
function singlePane(): boolean {
|
|
1124
|
+
return state.ui.narrow || state.ui.zoomed;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function setNarrow(v: boolean): void {
|
|
1128
|
+
if (state.ui.narrow === v) return;
|
|
1129
|
+
setState("ui", "narrow", v);
|
|
1130
|
+
recomputeVisible();
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* One step along the single-pane ring: `h` / `l` when only one pane is on
|
|
1135
|
+
* screen. Left and right stop meaning geometry there and mean sequence —
|
|
1136
|
+
* planner, each drawn board column, agenda, agents — closing into a ring.
|
|
1137
|
+
*
|
|
1138
|
+
* Returns false when it did not apply (not in single-pane, or the ring holds
|
|
1139
|
+
* a single pane), so the caller can fall back to today's behaviour.
|
|
1140
|
+
*/
|
|
1141
|
+
/** The ring as it stands right now, plus where the cursor sits on it. */
|
|
1142
|
+
function ringNow(): { ring: Pane[]; current: Pane } {
|
|
1143
|
+
const board = state.boards[state.ui.activeBoardIndex]?.board;
|
|
1144
|
+
const rendered = (board?.columns ?? [])
|
|
1145
|
+
.map((c, i) => ({ name: c.name, i }))
|
|
1146
|
+
.filter(({ name }) => !isHiddenColumn(config, name))
|
|
1147
|
+
.map(({ i }) => i);
|
|
1148
|
+
|
|
1149
|
+
const ring = buildRing({
|
|
1150
|
+
enabledZones: {
|
|
1151
|
+
planner: enabledZones.planner && desiredVisible.planner,
|
|
1152
|
+
board: rendered.length > 0,
|
|
1153
|
+
timeline: enabledZones.timeline && desiredVisible.timeline,
|
|
1154
|
+
agents: enabledZones.agents && desiredVisible.agents,
|
|
1155
|
+
},
|
|
1156
|
+
renderedColumns: rendered,
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
const current: Pane =
|
|
1160
|
+
state.ui.activeZone === "board"
|
|
1161
|
+
? { kind: "column", index: state.ui.col }
|
|
1162
|
+
: { kind: "zone", zone: state.ui.activeZone };
|
|
1163
|
+
|
|
1164
|
+
return { ring, current };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** What the single-pane top bar shows: this pane's name and its place. */
|
|
1168
|
+
function currentPane(): { label: string; position: { at: number; of: number } } | undefined {
|
|
1169
|
+
const { ring, current } = ringNow();
|
|
1170
|
+
if (ring.length === 0) return undefined;
|
|
1171
|
+
const board = state.boards[state.ui.activeBoardIndex]?.board;
|
|
1172
|
+
const label =
|
|
1173
|
+
current.kind === "column"
|
|
1174
|
+
? (board?.columns[current.index]?.name ?? "Board")
|
|
1175
|
+
: current.zone === "planner"
|
|
1176
|
+
? "Today / Tomorrow"
|
|
1177
|
+
: current.zone === "timeline"
|
|
1178
|
+
? "Agenda"
|
|
1179
|
+
: "Agents";
|
|
1180
|
+
return { label, position: ringPosition(ring, current) };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function stepPane(delta: 1 | -1): boolean {
|
|
1184
|
+
if (!singlePane()) return false;
|
|
1185
|
+
|
|
1186
|
+
const { ring, current } = ringNow();
|
|
1187
|
+
if (ring.length <= 1) return false;
|
|
1188
|
+
|
|
1189
|
+
const next = stepRing(ring, current, delta);
|
|
1190
|
+
if (samePane(next, current)) return false;
|
|
1191
|
+
|
|
1192
|
+
if (next.kind === "column") {
|
|
1193
|
+
setActiveZone("board");
|
|
1194
|
+
setCursor(next.index, 0);
|
|
1195
|
+
} else {
|
|
1196
|
+
setActiveZone(next.zone);
|
|
1197
|
+
}
|
|
1198
|
+
return true;
|
|
870
1199
|
}
|
|
871
1200
|
|
|
872
1201
|
function toggleZoom(): void {
|
|
1202
|
+
// Below the threshold there is nothing to zoom out to: leaving would put
|
|
1203
|
+
// back the cramped multi-column layout this mode exists to escape.
|
|
1204
|
+
if (state.ui.narrow) {
|
|
1205
|
+
flashBanner("info", "Nothing else fits at this width");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
873
1208
|
setState("ui", "zoomed", (z: boolean) => !z);
|
|
874
1209
|
}
|
|
875
1210
|
|
|
@@ -1308,6 +1643,15 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1308
1643
|
addTask,
|
|
1309
1644
|
deleteTask,
|
|
1310
1645
|
moveTaskWithinBoard,
|
|
1646
|
+
// boards
|
|
1647
|
+
addBoard,
|
|
1648
|
+
openBoardNew,
|
|
1649
|
+
closeBoardNew,
|
|
1650
|
+
boardNewChooseMode,
|
|
1651
|
+
boardNewMove,
|
|
1652
|
+
boardNewToggle,
|
|
1653
|
+
boardNewSubmitText,
|
|
1654
|
+
boardNewConfirmPick,
|
|
1311
1655
|
// ui
|
|
1312
1656
|
setActiveBoard,
|
|
1313
1657
|
setCursor,
|
|
@@ -1316,6 +1660,10 @@ export function createTuiStore({ config }: CreateStoreOptions) {
|
|
|
1316
1660
|
toggleZoneDesired,
|
|
1317
1661
|
applyResponsiveFits,
|
|
1318
1662
|
cycleActiveZone,
|
|
1663
|
+
singlePane,
|
|
1664
|
+
stepPane,
|
|
1665
|
+
currentPane,
|
|
1666
|
+
setNarrow,
|
|
1319
1667
|
toggleZoom,
|
|
1320
1668
|
toggleGrab,
|
|
1321
1669
|
exitGrab,
|
|
@@ -1375,15 +1723,34 @@ function noopCalendarStore(): CalendarStore {
|
|
|
1375
1723
|
};
|
|
1376
1724
|
}
|
|
1377
1725
|
|
|
1726
|
+
/**
|
|
1727
|
+
* Read and parse one board. Throws on failure — the caller decides what to do
|
|
1728
|
+
* with the message, because at runtime it must reach the screen through the
|
|
1729
|
+
* banner, never through stderr: writing to stderr while the renderer owns the
|
|
1730
|
+
* alternate screen is what breaks the layout.
|
|
1731
|
+
*/
|
|
1732
|
+
/** `~` is what people type; node's fs does not know it. */
|
|
1733
|
+
function expandHome(p: string): string {
|
|
1734
|
+
if (p === "~") return homedir();
|
|
1735
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
1736
|
+
return p;
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
function loadOne(path: string, name?: string): LoadedBoard {
|
|
1740
|
+
const content = readFileSync(path, "utf-8");
|
|
1741
|
+
const { board } = parseBoard(content, { filepath: path });
|
|
1742
|
+
if (name) board.name = name;
|
|
1743
|
+
return { board, mtimeMs: statMtime(path) };
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1378
1746
|
function loadAll(config: Config): LoadedBoard[] {
|
|
1379
1747
|
const out: LoadedBoard[] = [];
|
|
1380
1748
|
for (const b of config.boards) {
|
|
1381
1749
|
try {
|
|
1382
|
-
|
|
1383
|
-
const { board } = parseBoard(content, { filepath: b.path });
|
|
1384
|
-
if (b.name) board.name = b.name;
|
|
1385
|
-
out.push({ board, mtimeMs: statMtime(b.path) });
|
|
1750
|
+
out.push(loadOne(b.path, b.name));
|
|
1386
1751
|
} catch (e) {
|
|
1752
|
+
// Startup only, before the renderer takes the screen — safe to print,
|
|
1753
|
+
// and better than starting with a board silently missing.
|
|
1387
1754
|
console.error(`Skipping ${b.path}: ${(e as Error).message}`);
|
|
1388
1755
|
}
|
|
1389
1756
|
}
|
package/src/ui/BoardView.tsx
CHANGED
|
@@ -68,6 +68,8 @@ function columnId(boardPath: string, idx: number): string {
|
|
|
68
68
|
|
|
69
69
|
export function BoardView(props: BoardViewProps) {
|
|
70
70
|
const ui = () => props.store.state.ui;
|
|
71
|
+
// One pane on screen — by the user's `z` or by the terminal's width.
|
|
72
|
+
const singlePane = () => props.store.singlePane();
|
|
71
73
|
// Width of the clipping viewport (the board zone), read from layout.
|
|
72
74
|
let viewportRef: SizedBoxLike | undefined;
|
|
73
75
|
// Horizontal scroll offset in cells, applied as a negative left margin on
|
|
@@ -95,7 +97,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
95
97
|
* (Python kanban `z`).
|
|
96
98
|
*/
|
|
97
99
|
const renderedColumns = createMemo(() => {
|
|
98
|
-
if (!
|
|
100
|
+
if (!singlePane() || ui().activeZone === "planner") return visibleColumns();
|
|
99
101
|
const cols = visibleColumns();
|
|
100
102
|
// ui.col is a board.columns index (carries Archive); map it to the
|
|
101
103
|
// rendered list so zoom focuses the column actually under the cursor.
|
|
@@ -118,7 +120,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
118
120
|
// geometry used everywhere (COL_WIDTH + COL_GAP).
|
|
119
121
|
createEffect(() => {
|
|
120
122
|
const colIdx = ui().col;
|
|
121
|
-
if (
|
|
123
|
+
if (singlePane() || ui().activeZone === "planner") {
|
|
122
124
|
setScrollX(0);
|
|
123
125
|
return;
|
|
124
126
|
}
|
|
@@ -156,7 +158,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
156
158
|
*/
|
|
157
159
|
const columnTasksVisible = (i: number): boolean => {
|
|
158
160
|
const vw = viewportW();
|
|
159
|
-
if (vw <= 0 ||
|
|
161
|
+
if (vw <= 0 || singlePane()) return true;
|
|
160
162
|
const stride = COL_WIDTH + COL_GAP;
|
|
161
163
|
const start = i * stride;
|
|
162
164
|
const left = Math.max(start, scrollX());
|
|
@@ -192,11 +194,11 @@ export function BoardView(props: BoardViewProps) {
|
|
|
192
194
|
<box
|
|
193
195
|
style={{
|
|
194
196
|
flexDirection: "row",
|
|
195
|
-
flexGrow:
|
|
197
|
+
flexGrow: singlePane() ? 1 : 0,
|
|
196
198
|
flexShrink: 0,
|
|
197
199
|
height: "100%",
|
|
198
200
|
alignItems: "stretch",
|
|
199
|
-
marginLeft:
|
|
201
|
+
marginLeft: singlePane() ? 0 : -scrollX(),
|
|
200
202
|
}}
|
|
201
203
|
>
|
|
202
204
|
<For each={renderedColumns()}>
|
|
@@ -211,7 +213,7 @@ export function BoardView(props: BoardViewProps) {
|
|
|
211
213
|
column={col}
|
|
212
214
|
columnIndex={originalIndex}
|
|
213
215
|
active={isActive()}
|
|
214
|
-
zoomed={
|
|
216
|
+
zoomed={singlePane() && isActive()}
|
|
215
217
|
tasksVisible={columnTasksVisible(i())}
|
|
216
218
|
boxId={columnId(props.board.filepath, originalIndex)}
|
|
217
219
|
/>
|
package/src/ui/Chrome.tsx
CHANGED
|
@@ -30,7 +30,21 @@ export function TopBar(props: { store: TuiStore }) {
|
|
|
30
30
|
return { open, done, cols };
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
// Single-pane: one zone fills the screen, so the tab strip has nothing left
|
|
34
|
+
// to orient anybody — you cannot see the zones you are not in. It is replaced
|
|
35
|
+
// by where you are in the ring. When the two compete for room the position
|
|
36
|
+
// wins: the board name is one keystroke from being obvious, the ring
|
|
37
|
+
// position is not.
|
|
38
|
+
const singlePane = () => props.store.singlePane();
|
|
39
|
+
const paneLabel = () => {
|
|
40
|
+
const p = props.store.currentPane();
|
|
41
|
+
if (!p) return "";
|
|
42
|
+
const { at, of } = p.position;
|
|
43
|
+
return `⤢ ${p.label} ‹ ${at + 1}/${of} ›`;
|
|
44
|
+
};
|
|
45
|
+
|
|
33
46
|
return (
|
|
47
|
+
<Show when={!singlePane()} fallback={<CompactBar store={props.store} label={paneLabel()} />}>
|
|
34
48
|
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
|
|
35
49
|
<box style={{ flexDirection: "row", flexShrink: 1, overflow: "hidden" }}>
|
|
36
50
|
{/* Brand + date */}
|
|
@@ -63,6 +77,16 @@ export function TopBar(props: { store: TuiStore }) {
|
|
|
63
77
|
);
|
|
64
78
|
}}
|
|
65
79
|
</For>
|
|
80
|
+
{/* New board — the browser's new-tab button, in a terminal. Clickable
|
|
81
|
+
like the tabs beside it; `+` does the same from the keyboard. */}
|
|
82
|
+
<box
|
|
83
|
+
style={{ flexShrink: 0, flexDirection: "row" }}
|
|
84
|
+
onMouseDown={() => props.store.openBoardNew()}
|
|
85
|
+
>
|
|
86
|
+
<text wrapMode="none">
|
|
87
|
+
<span style={{ fg: T.textDim }}> + </span>
|
|
88
|
+
</text>
|
|
89
|
+
</box>
|
|
66
90
|
</box>
|
|
67
91
|
<Show when={activeStats()}>
|
|
68
92
|
<text wrapMode="none" style={{ flexShrink: 0, marginLeft: 2 }}>
|
|
@@ -72,11 +96,51 @@ export function TopBar(props: { store: TuiStore }) {
|
|
|
72
96
|
</text>
|
|
73
97
|
</Show>
|
|
74
98
|
</box>
|
|
99
|
+
</Show>
|
|
75
100
|
);
|
|
76
101
|
}
|
|
77
102
|
|
|
103
|
+
/**
|
|
104
|
+
* The narrow-terminal top bar: board name, then the ring position. Everything
|
|
105
|
+
* else — the date, the per-board counters, the other tabs — is dropped rather
|
|
106
|
+
* than truncated mid-word, which is what the full bar does at 60 columns.
|
|
107
|
+
*/
|
|
108
|
+
function CompactBar(props: { store: TuiStore; label: string }) {
|
|
109
|
+
const boardName = () =>
|
|
110
|
+
props.store.state.boards[props.store.state.ui.activeBoardIndex]?.board.name ?? "";
|
|
111
|
+
return (
|
|
112
|
+
<box style={{ flexDirection: "row", justifyContent: "space-between", height: 1 }}>
|
|
113
|
+
<text wrapMode="none" style={{ flexShrink: 1 }}>
|
|
114
|
+
<span style={{ fg: T.textDim }}>{boardName() + " "}</span>
|
|
115
|
+
</text>
|
|
116
|
+
<text wrapMode="none" style={{ flexShrink: 0 }}>
|
|
117
|
+
<span style={{ fg: T.accent, attributes: ATTR.bold }}>{props.label}</span>
|
|
118
|
+
</text>
|
|
119
|
+
</box>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Curated cheat-sheet: only the keys that keep you unstuck (move, switch
|
|
125
|
+
* zone/board, help, quit) plus the highest-frequency, on-brand actions (done,
|
|
126
|
+
* new, schedule). Everything else — zoom, toggles, multi-select,
|
|
127
|
+
* edit/assign/archive/delete, undo — lives in `?`.
|
|
128
|
+
*/
|
|
129
|
+
const HINTS_FULL =
|
|
130
|
+
"hjkl ↑↓←→ move · Tab board · ⇧Tab zone · ⏎ done · n new · t today · b block · c schedule · r refresh · z zoom · ? help · q quit";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Single-pane keeps the ones that matter with one pane on screen: walking the
|
|
134
|
+
* ring, jumping zones, completing, and the way out. The full line is 130
|
|
135
|
+
* characters and this bar truncates rather than wraps, so at 60 columns the
|
|
136
|
+
* choice is not "which keys fit" but "which keys are still readable" — a hint
|
|
137
|
+
* cut to `⏎ don…` is worse than no hint.
|
|
138
|
+
*/
|
|
139
|
+
const HINTS_COMPACT = "hl pane · ⇧Tab zone · ⏎ done · ? help · q quit";
|
|
140
|
+
|
|
78
141
|
export function BottomBar(props: { store: TuiStore }) {
|
|
79
142
|
const banner = () => props.store.state.ui.banner;
|
|
143
|
+
const hints = () => (props.store.singlePane() ? HINTS_COMPACT : HINTS_FULL);
|
|
80
144
|
return (
|
|
81
145
|
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
82
146
|
<box style={{ height: 1, flexDirection: "row" }}>
|
|
@@ -107,16 +171,8 @@ export function BottomBar(props: { store: TuiStore }) {
|
|
|
107
171
|
</Show>
|
|
108
172
|
</box>
|
|
109
173
|
<box style={{ height: 1, flexDirection: "row" }}>
|
|
110
|
-
{/*
|
|
111
|
-
Curated cheat-sheet: only the keys that keep you unstuck (move,
|
|
112
|
-
switch zone/board, help, quit) plus the highest-frequency, on-brand
|
|
113
|
-
actions (done, new, schedule). Everything else — zoom, toggles,
|
|
114
|
-
multi-select, edit/assign/archive/delete, undo — lives in `?`.
|
|
115
|
-
*/}
|
|
116
174
|
<text wrapMode="none" truncate>
|
|
117
|
-
<span style={{ fg: T.textDim }}>
|
|
118
|
-
{"hjkl ↑↓←→ move · Tab board · ⇧Tab zone · ⏎ done · n new · t today · b block · c schedule · r refresh · z zoom · ? help · q quit"}
|
|
119
|
-
</span>
|
|
175
|
+
<span style={{ fg: T.textDim }}>{hints()}</span>
|
|
120
176
|
</text>
|
|
121
177
|
</box>
|
|
122
178
|
</box>
|