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.
@@ -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 { Config } from "~/config/loader";
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
- if (!v[state.ui.activeZone]) setActiveZone("board");
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
- function applyResponsiveFits(fits: Partial<Record<ActiveZone, boolean>>): void {
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 visible = ZONE_ORDER.filter((z) => state.ui.visibleZones[z]);
866
- if (visible.length <= 1) return;
867
- const currentIdx = visible.indexOf(state.ui.activeZone);
868
- const nextIdx = (currentIdx + 1) % visible.length;
869
- setActiveZone(visible[nextIdx]!);
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
- const content = readFileSync(b.path, "utf-8");
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
  }
@@ -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 (!ui().zoomed || ui().activeZone === "planner") return visibleColumns();
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 (ui().zoomed || ui().activeZone === "planner") {
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 || ui().zoomed) return true;
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: ui().zoomed ? 1 : 0,
197
+ flexGrow: singlePane() ? 1 : 0,
196
198
  flexShrink: 0,
197
199
  height: "100%",
198
200
  alignItems: "stretch",
199
- marginLeft: ui().zoomed ? 0 : -scrollX(),
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={ui().zoomed && isActive()}
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>