aethel 0.2.6 → 0.3.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/src/tui/app.js CHANGED
@@ -1,25 +1,20 @@
1
1
  import path from "node:path";
2
+ import { spawn } from "node:child_process";
2
3
  import React, { useEffect, useMemo, useState } from "react";
3
4
  import { Box, Text, useApp, useInput } from "ink";
4
5
  import Spinner from "ink-spinner";
5
6
  import TextInput from "ink-text-input";
6
7
  import {
7
- batchOperateFiles,
8
- getAccountInfo,
9
8
  humanSize,
10
9
  iconForMime,
11
- listAccessibleFiles,
12
10
  sourceBadgeForItem,
13
- syncLocalDirectoryToParent,
14
- uploadLocalEntry,
15
11
  } from "../core/drive-api.js";
16
12
  import {
17
13
  defaultLocalRoot,
18
- deleteLocalEntry,
19
14
  ensureLocalDirectory,
20
- listLocalEntries,
21
- renameLocalEntry,
22
15
  } from "../core/local-fs.js";
16
+ import { COMMAND_CATALOG } from "./command-catalog.js";
17
+ import { parseCommandInput } from "./commands.js";
23
18
 
24
19
  const h = React.createElement;
25
20
 
@@ -137,25 +132,148 @@ function renderHelp() {
137
132
  marginTop: 1,
138
133
  flexDirection: "column",
139
134
  },
140
- h(Text, { bold: true }, "Aethel TUI - Keyboard Shortcuts"),
141
- h(Text, null, "Tab Switch focus between Drive and Local panes"),
142
- h(Text, null, "Up/Down, j/k Navigate the focused pane"),
143
- h(Text, null, "Left/Right Move to parent or enter directory in focused pane"),
144
- h(Text, null, "u Upload selected local entry to current Drive directory"),
145
- h(Text, null, "s Sync selected local directory contents to current Drive directory"),
146
- h(Text, null, "n Rename selected local file or directory"),
147
- h(Text, null, "x Delete selected local file or directory"),
148
- h(Text, null, "Space Toggle Drive selection in Drive pane"),
149
- h(Text, null, "t / d Trash or permanently delete selected Drive items"),
150
- h(Text, null, "/ Filter the focused pane"),
151
- h(Text, null, "U Manually enter a local path and upload"),
152
- h(Text, null, "r Reload Drive and Local panes"),
153
- h(Text, null, "q Quit"),
154
- h(Text, { dimColor: true }, "Press any key to close this help.")
135
+ h(Text, { bold: true }, "Keyboard Shortcuts"),
136
+ h(Text, null, ""),
137
+ h(Text, { color: "cyan" }, "Navigation"),
138
+ h(Text, null, " Tab Switch panes Left/Right Parent / Enter dir"),
139
+ h(Text, null, " j/k Move cursor / Filter by name"),
140
+ h(Text, null, ""),
141
+ h(Text, { color: "cyan" }, "Local Pane"),
142
+ h(Text, null, " u Upload to Drive s Sync dir to Drive U Upload by path"),
143
+ h(Text, null, " n Rename x Delete"),
144
+ h(Text, null, ""),
145
+ h(Text, { color: "cyan" }, "Drive Pane"),
146
+ h(Text, null, " Space Toggle select a Select all t Trash d Delete"),
147
+ h(Text, null, ""),
148
+ h(Text, { color: "cyan" }, "Commands"),
149
+ h(Text, null, " f Open command panel : Run CLI command directly"),
150
+ h(Text, null, " r Reload panes q Quit"),
151
+ h(Text, { dimColor: true }, "Press any key to close.")
155
152
  );
156
153
  }
157
154
 
158
- export function AethelTui({ drive, includeSharedDrives = false }) {
155
+ function renderCommandCatalog(width, height, cursor) {
156
+ const listHeight = Math.max(height - 7, 6);
157
+ const start = scrollWindow(COMMAND_CATALOG.length, cursor, listHeight);
158
+ const visibleEntries = COMMAND_CATALOG.slice(start, start + listHeight);
159
+ const currentEntry = COMMAND_CATALOG[cursor] || null;
160
+
161
+ return h(
162
+ Box,
163
+ {
164
+ borderStyle: "round",
165
+ borderColor: "cyan",
166
+ paddingX: 1,
167
+ flexDirection: "column",
168
+ },
169
+ ...visibleEntries.map((entry, index) =>
170
+ h(
171
+ Text,
172
+ {
173
+ key: entry.name,
174
+ inverse: start + index === cursor,
175
+ color: start + index === cursor ? "cyan" : undefined,
176
+ wrap: "truncate-end",
177
+ },
178
+ truncate(`${entry.name.padEnd(16, " ")} ${entry.description}`, width - 4)
179
+ )
180
+ ),
181
+ currentEntry
182
+ ? h(
183
+ Text,
184
+ { dimColor: true },
185
+ truncate(`> aethel ${currentEntry.template}`, width - 4)
186
+ )
187
+ : null
188
+ );
189
+ }
190
+
191
+ function renderCommandActions(width, height, command, cursor) {
192
+ const actions = [
193
+ ...command.actions,
194
+ { label: "Custom Command", command: command.template },
195
+ ];
196
+ const listHeight = Math.max(height - 8, 5);
197
+ const start = scrollWindow(actions.length, cursor, listHeight);
198
+ const visibleEntries = actions.slice(start, start + listHeight);
199
+ const currentAction = actions[cursor] || null;
200
+
201
+ return h(
202
+ Box,
203
+ {
204
+ borderStyle: "round",
205
+ borderColor: "cyan",
206
+ paddingX: 1,
207
+ flexDirection: "column",
208
+ },
209
+ h(Text, { bold: true }, truncate(`${command.name}`, width - 4)),
210
+ ...visibleEntries.map((entry, index) =>
211
+ h(
212
+ Text,
213
+ {
214
+ key: `${command.name}-${entry.label}`,
215
+ inverse: start + index === cursor,
216
+ color: start + index === cursor ? "cyan" : undefined,
217
+ wrap: "truncate-end",
218
+ },
219
+ truncate(entry.label, width - 4)
220
+ )
221
+ ),
222
+ currentAction
223
+ ? h(
224
+ Text,
225
+ { dimColor: true },
226
+ truncate(`> aethel ${currentAction.command}`, width - 4)
227
+ )
228
+ : null
229
+ );
230
+ }
231
+
232
+ function renderCommandOutput(commandResult, width, height, scroll) {
233
+ const outputLines = commandResult.output
234
+ ? commandResult.output.split(/\r?\n/)
235
+ : ["(no output)"];
236
+ const bodyHeight = Math.max(height - 8, 4);
237
+ const maxScroll = Math.max(outputLines.length - bodyHeight, 0);
238
+ const start = Math.min(scroll, maxScroll);
239
+ const visibleLines = outputLines.slice(start, start + bodyHeight);
240
+
241
+ return h(
242
+ Box,
243
+ {
244
+ borderStyle: "round",
245
+ borderColor: commandResult.exitCode === 0 ? "cyan" : "red",
246
+ paddingX: 1,
247
+ marginTop: 1,
248
+ flexDirection: "column",
249
+ },
250
+ h(Text, { bold: true }, truncate(`Command: aethel ${commandResult.command}`, width - 4)),
251
+ h(
252
+ Text,
253
+ { color: commandResult.exitCode === 0 ? "green" : "red" },
254
+ `Exit code: ${commandResult.exitCode}`
255
+ ),
256
+ ...visibleLines.map((line, index) =>
257
+ h(
258
+ Text,
259
+ { key: `${start + index}`, wrap: "truncate-end" },
260
+ truncate(line || " ", width - 4)
261
+ )
262
+ ),
263
+ h(
264
+ Text,
265
+ { dimColor: true },
266
+ truncate("Up/Down/PageUp/PageDown/Home/End: scroll Enter/Esc: close", width - 4)
267
+ )
268
+ );
269
+ }
270
+
271
+ export function AethelTui({
272
+ repo,
273
+ includeSharedDrives = false,
274
+ cliPath = null,
275
+ cliArgs = [],
276
+ }) {
159
277
  const { exit } = useApp();
160
278
  const [mode, setMode] = useState("loading");
161
279
  const [remoteLoading, setRemoteLoading] = useState(true);
@@ -175,6 +293,11 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
175
293
  const [errorMessage, setErrorMessage] = useState("");
176
294
  const [pendingAction, setPendingAction] = useState(null);
177
295
  const [inputValue, setInputValue] = useState("");
296
+ const [commandResult, setCommandResult] = useState(null);
297
+ const [commandScroll, setCommandScroll] = useState(0);
298
+ const [commandCursor, setCommandCursor] = useState(0);
299
+ const [commandActionCursor, setCommandActionCursor] = useState(0);
300
+ const [commandReturnMode, setCommandReturnMode] = useState("normal");
178
301
  const width = process.stdout.columns || 80;
179
302
  const height = process.stdout.rows || 24;
180
303
  const currentRemoteFolderId = remoteFolderStack.length
@@ -236,6 +359,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
236
359
 
237
360
  const currentLocalEntry = filteredLocalEntries[localCursor] || null;
238
361
  const currentRemoteEntry = filteredRemoteEntries[remoteCursor] || null;
362
+ const currentCatalogCommand = COMMAND_CATALOG[commandCursor] || null;
239
363
  const currentRemoteFolderMeta = currentRemoteFolderId
240
364
  ? remoteFiles.find((file) => file.id === currentRemoteFolderId) || null
241
365
  : null;
@@ -255,9 +379,15 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
255
379
  );
256
380
  }, [filteredLocalEntries.length]);
257
381
 
382
+ useEffect(() => {
383
+ setCommandCursor((current) =>
384
+ Math.min(current, Math.max(COMMAND_CATALOG.length - 1, 0))
385
+ );
386
+ }, []);
387
+
258
388
  async function loadLocalPane(nextDirectory = localDirectory) {
259
389
  const resolvedDirectory = await ensureLocalDirectory(nextDirectory);
260
- const items = await listLocalEntries(resolvedDirectory);
390
+ const items = await repo.listLocalEntries(resolvedDirectory);
261
391
  setLocalDirectory(resolvedDirectory);
262
392
  setLocalEntries(items);
263
393
  setLocalCursor(0);
@@ -282,13 +412,17 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
282
412
  // Fetch remote in background
283
413
  try {
284
414
  const [nextAccount, nextRemoteFiles] = await Promise.all([
285
- getAccountInfo(drive),
286
- listAccessibleFiles(drive, includeSharedDrives),
415
+ repo.getAccountInfo(),
416
+ repo.listRemoteFiles({ includeSharedDrives }),
287
417
  ]);
418
+ const remoteIds = new Set(nextRemoteFiles.map((file) => file.id));
419
+ const resolvedRemoteFolderStack = nextRemoteFolderStack.filter((folder) =>
420
+ remoteIds.has(folder.id)
421
+ );
288
422
 
289
423
  setAccount(nextAccount);
290
424
  setRemoteFiles(nextRemoteFiles);
291
- setRemoteFolderStack(nextRemoteFolderStack);
425
+ setRemoteFolderStack(resolvedRemoteFolderStack);
292
426
  setSelectedRemoteIds(new Set());
293
427
  setRemoteLoading(false);
294
428
  setStatus(
@@ -313,7 +447,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
313
447
 
314
448
  setMode("busy");
315
449
  try {
316
- const result = await batchOperateFiles(drive, targets, {
450
+ const result = await repo.batchOperateFiles(targets, {
317
451
  permanent,
318
452
  includeSharedDrives,
319
453
  onProgress: (done, total, verb, name) => {
@@ -341,8 +475,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
341
475
 
342
476
  setMode("busy");
343
477
  try {
344
- const result = await uploadLocalEntry(
345
- drive,
478
+ const result = await repo.uploadLocalEntry(
346
479
  trimmedPath,
347
480
  currentUploadParentId,
348
481
  (verb, localPath, name) => {
@@ -368,8 +501,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
368
501
  async function executeSyncDirectory(targetPath) {
369
502
  setMode("busy");
370
503
  try {
371
- const result = await syncLocalDirectoryToParent(
372
- drive,
504
+ const result = await repo.syncLocalDirectory(
373
505
  targetPath,
374
506
  currentUploadParentId,
375
507
  (verb, localPath, name) => {
@@ -394,7 +526,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
394
526
  async function executeLocalDelete(targetPath) {
395
527
  setMode("busy");
396
528
  try {
397
- await deleteLocalEntry(targetPath);
529
+ await repo.deleteLocalEntry(targetPath);
398
530
  await loadLocalPane(path.dirname(targetPath) === targetPath ? localDirectory : localDirectory);
399
531
  setMode("normal");
400
532
  setStatus(`Deleted local entry: ${path.basename(targetPath)}`);
@@ -407,7 +539,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
407
539
  async function executeLocalRename(targetPath, nextName) {
408
540
  setMode("busy");
409
541
  try {
410
- const renamedPath = await renameLocalEntry(targetPath, nextName);
542
+ const renamedPath = await repo.renameLocalEntry(targetPath, nextName);
411
543
  await loadLocalPane(path.dirname(renamedPath));
412
544
  setMode("normal");
413
545
  setStatus(`Renamed local entry to: ${path.basename(renamedPath)}`);
@@ -436,6 +568,91 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
436
568
  );
437
569
  }
438
570
 
571
+ function openCommandEditor(nextValue, returnMode = "normal") {
572
+ setInputValue(nextValue);
573
+ setCommandReturnMode(returnMode);
574
+ setMode("command");
575
+ setStatus("Edit the command and press Enter to run.");
576
+ }
577
+
578
+ function openCommandActions(index = commandCursor) {
579
+ setCommandCursor(index);
580
+ setCommandActionCursor(0);
581
+ setMode("command-actions");
582
+ setStatus("Choose a TUI action or edit the command.");
583
+ }
584
+
585
+ async function executeCliCommand(rawCommand, returnMode = commandReturnMode) {
586
+ if (!cliPath) {
587
+ setMode("normal");
588
+ setStatus("CLI command runner is unavailable.");
589
+ return;
590
+ }
591
+
592
+ setCommandReturnMode(returnMode);
593
+
594
+ let args;
595
+ try {
596
+ args = parseCommandInput(rawCommand);
597
+ } catch (error) {
598
+ setMode(returnMode);
599
+ setStatus(error.message);
600
+ return;
601
+ }
602
+
603
+ if (args.length === 0) {
604
+ setMode(returnMode);
605
+ setStatus("Command cancelled.");
606
+ return;
607
+ }
608
+
609
+ setMode("busy");
610
+ setStatus(`Running: aethel ${args.join(" ")}`);
611
+
612
+ const output = await new Promise((resolve) => {
613
+ const child = spawn(process.execPath, [cliPath, ...cliArgs, ...args], {
614
+ cwd: process.cwd(),
615
+ env: process.env,
616
+ stdio: ["ignore", "pipe", "pipe"],
617
+ });
618
+
619
+ let stdout = "";
620
+ let stderr = "";
621
+
622
+ child.stdout.on("data", (chunk) => {
623
+ stdout += chunk.toString();
624
+ });
625
+ child.stderr.on("data", (chunk) => {
626
+ stderr += chunk.toString();
627
+ });
628
+ child.on("error", (error) => {
629
+ stderr += `${error.message}\n`;
630
+ });
631
+ child.on("close", (exitCode) => {
632
+ resolve({
633
+ command: args.join(" "),
634
+ exitCode: exitCode ?? 1,
635
+ output: [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n"),
636
+ });
637
+ });
638
+ });
639
+
640
+ setInputValue("");
641
+ setCommandResult(output);
642
+ setCommandScroll(
643
+ Math.max(output.output.split(/\r?\n/).length - Math.max(height - 8, 4), 0)
644
+ );
645
+
646
+ if (output.exitCode === 0) {
647
+ await loadAllData(`Command finished: aethel ${output.command}`, true);
648
+ } else {
649
+ setStatus(`Command failed: aethel ${output.command}`);
650
+ setMode("normal");
651
+ }
652
+
653
+ setMode("command-output");
654
+ }
655
+
439
656
  useInput((input, key) => {
440
657
  if ((key.ctrl && input === "c") || (mode === "error" && input === "q")) {
441
658
  exit();
@@ -458,6 +675,183 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
458
675
  return;
459
676
  }
460
677
 
678
+ if (mode === "commands-page") {
679
+ if (key.escape || input === "f" || input === "F") {
680
+ setMode("normal");
681
+ setStatus("Closed commands page.");
682
+ return;
683
+ }
684
+
685
+ if (input === ":") {
686
+ openCommandEditor("", "commands-page");
687
+ return;
688
+ }
689
+
690
+ if (key.return || key.rightArrow) {
691
+ if (currentCatalogCommand) {
692
+ openCommandActions(commandCursor);
693
+ }
694
+ return;
695
+ }
696
+
697
+ if (key.upArrow || input === "k") {
698
+ setCommandCursor((current) => Math.max(current - 1, 0));
699
+ return;
700
+ }
701
+
702
+ if (key.downArrow || input === "j") {
703
+ setCommandCursor((current) =>
704
+ Math.min(current + 1, Math.max(COMMAND_CATALOG.length - 1, 0))
705
+ );
706
+ return;
707
+ }
708
+
709
+ if (key.pageUp) {
710
+ setCommandCursor((current) =>
711
+ Math.max(current - Math.max(height - 11, 6), 0)
712
+ );
713
+ return;
714
+ }
715
+
716
+ if (key.pageDown) {
717
+ setCommandCursor((current) =>
718
+ Math.min(
719
+ current + Math.max(height - 11, 6),
720
+ Math.max(COMMAND_CATALOG.length - 1, 0)
721
+ )
722
+ );
723
+ return;
724
+ }
725
+
726
+ if (key.home) {
727
+ setCommandCursor(0);
728
+ return;
729
+ }
730
+
731
+ if (key.end) {
732
+ setCommandCursor(Math.max(COMMAND_CATALOG.length - 1, 0));
733
+ }
734
+ return;
735
+ }
736
+
737
+ if (mode === "command-actions") {
738
+ if (key.escape || key.leftArrow) {
739
+ setMode("commands-page");
740
+ setStatus("Back to commands list.");
741
+ return;
742
+ }
743
+
744
+ if (!currentCatalogCommand) {
745
+ setMode("commands-page");
746
+ return;
747
+ }
748
+
749
+ const availableActions = [
750
+ ...currentCatalogCommand.actions,
751
+ { label: "Custom Command", command: currentCatalogCommand.template },
752
+ ];
753
+ const currentAction = availableActions[commandActionCursor] || availableActions[0];
754
+
755
+ if (input === "e" || input === "E") {
756
+ openCommandEditor(currentAction.command, "command-actions");
757
+ return;
758
+ }
759
+
760
+ if (key.return) {
761
+ if (currentAction.label === "Custom Command") {
762
+ openCommandEditor(currentAction.command, "command-actions");
763
+ } else {
764
+ void executeCliCommand(currentAction.command, "command-actions");
765
+ }
766
+ return;
767
+ }
768
+
769
+ if (key.upArrow || input === "k") {
770
+ setCommandActionCursor((current) => Math.max(current - 1, 0));
771
+ return;
772
+ }
773
+
774
+ if (key.downArrow || input === "j") {
775
+ setCommandActionCursor((current) =>
776
+ Math.min(current + 1, Math.max(availableActions.length - 1, 0))
777
+ );
778
+ return;
779
+ }
780
+
781
+ if (key.pageUp) {
782
+ setCommandActionCursor((current) =>
783
+ Math.max(current - Math.max(height - 12, 5), 0)
784
+ );
785
+ return;
786
+ }
787
+
788
+ if (key.pageDown) {
789
+ setCommandActionCursor((current) =>
790
+ Math.min(
791
+ current + Math.max(height - 12, 5),
792
+ Math.max(availableActions.length - 1, 0)
793
+ )
794
+ );
795
+ return;
796
+ }
797
+
798
+ if (key.home) {
799
+ setCommandActionCursor(0);
800
+ return;
801
+ }
802
+
803
+ if (key.end) {
804
+ setCommandActionCursor(Math.max(availableActions.length - 1, 0));
805
+ }
806
+ return;
807
+ }
808
+
809
+ if (mode === "command-output") {
810
+ if (key.escape || key.return || input === "q" || input === "Q") {
811
+ setCommandResult(null);
812
+ setCommandScroll(0);
813
+ setMode(commandReturnMode);
814
+ return;
815
+ }
816
+
817
+ if (key.upArrow || input === "k") {
818
+ setCommandScroll((current) => Math.max(current - 1, 0));
819
+ return;
820
+ }
821
+
822
+ if (key.downArrow || input === "j") {
823
+ const lines = commandResult?.output?.split(/\r?\n/).length || 1;
824
+ const maxScroll = Math.max(lines - Math.max(height - 8, 4), 0);
825
+ setCommandScroll((current) => Math.min(current + 1, maxScroll));
826
+ return;
827
+ }
828
+
829
+ if (key.pageUp) {
830
+ setCommandScroll((current) => Math.max(current - Math.max(height - 8, 4), 0));
831
+ return;
832
+ }
833
+
834
+ if (key.pageDown) {
835
+ const lines = commandResult?.output?.split(/\r?\n/).length || 1;
836
+ const maxScroll = Math.max(lines - Math.max(height - 8, 4), 0);
837
+ setCommandScroll((current) =>
838
+ Math.min(current + Math.max(height - 8, 4), maxScroll)
839
+ );
840
+ return;
841
+ }
842
+
843
+ if (key.home) {
844
+ setCommandScroll(0);
845
+ return;
846
+ }
847
+
848
+ if (key.end) {
849
+ const lines = commandResult?.output?.split(/\r?\n/).length || 1;
850
+ setCommandScroll(Math.max(lines - Math.max(height - 8, 4), 0));
851
+ }
852
+ return;
853
+ }
854
+
461
855
  if (mode === "confirm") {
462
856
  if (input === "y" || input === "Y") {
463
857
  const action = pendingAction;
@@ -542,6 +936,15 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
542
936
  return;
543
937
  }
544
938
 
939
+ if (mode === "command") {
940
+ if (key.escape) {
941
+ setInputValue("");
942
+ setMode(commandReturnMode);
943
+ setStatus("Command cancelled.");
944
+ }
945
+ return;
946
+ }
947
+
545
948
  if (mode === "rename") {
546
949
  if (key.escape) {
547
950
  setInputValue("");
@@ -573,6 +976,18 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
573
976
  return;
574
977
  }
575
978
 
979
+ if (input === ":") {
980
+ openCommandEditor("", "normal");
981
+ setStatus("Enter an Aethel command without `aethel`.");
982
+ return;
983
+ }
984
+
985
+ if (input === "f" || input === "F") {
986
+ setMode("commands-page");
987
+ setStatus("Browse commands and press Enter to edit one.");
988
+ return;
989
+ }
990
+
576
991
  if (input === "r" || input === "R") {
577
992
  void loadAllData("Reloaded Drive and Local panes.", true);
578
993
  return;
@@ -704,7 +1119,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
704
1119
  }
705
1120
 
706
1121
  if (focusPane === "local") {
707
- if (input === "u" || input === "U") {
1122
+ if (input === "u") {
708
1123
  if (!currentLocalEntry) {
709
1124
  setStatus("No local item is selected.");
710
1125
  return;
@@ -873,7 +1288,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
873
1288
  : "Drive: /";
874
1289
  const localBreadcrumb = `Local: ${localDirectory}`;
875
1290
  const selectedCount = selectedRemoteIds.size;
876
- const contentHeight = Math.max(height - 10, 6);
1291
+ const contentHeight = Math.max(height - 7, 6);
877
1292
  const paneWidth = Math.max(Math.floor((width - 3) / 2), 24);
878
1293
 
879
1294
  if (mode === "error") {
@@ -899,49 +1314,77 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
899
1314
  );
900
1315
  }
901
1316
 
902
- let hints =
903
- "Tab:focus Left/Right:navigate u:upload local s:sync dir n:rename local x:delete local Space:select drive t/d:delete drive /:filter U:manual upload r:reload q:quit ?:help";
1317
+ if (mode === "commands-page") {
1318
+ return h(
1319
+ Box,
1320
+ { flexDirection: "column" },
1321
+ h(Text, { color: "cyan", bold: true }, truncate("Aethel Commands", width)),
1322
+ h(Text, { dimColor: true }, truncate(status, width)),
1323
+ renderCommandCatalog(width, height, commandCursor),
1324
+ h(
1325
+ Text,
1326
+ { dimColor: true },
1327
+ truncate("j/k:move Enter:open ::custom command Esc:close", width)
1328
+ )
1329
+ );
1330
+ }
1331
+
1332
+ if (mode === "command-actions" && currentCatalogCommand) {
1333
+ return h(
1334
+ Box,
1335
+ { flexDirection: "column" },
1336
+ h(Text, { color: "cyan", bold: true }, truncate("Aethel Commands", width)),
1337
+ h(Text, { dimColor: true }, truncate(status, width)),
1338
+ renderCommandActions(width, height, currentCatalogCommand, commandActionCursor),
1339
+ h(
1340
+ Text,
1341
+ { dimColor: true },
1342
+ truncate("j/k:move Enter:run e:edit Esc:back", width)
1343
+ )
1344
+ );
1345
+ }
1346
+
1347
+ let hints;
904
1348
  if (mode === "filter") {
905
- hints = "Enter: apply filter Esc: cancel";
1349
+ hints = "Enter: apply Esc: cancel";
906
1350
  } else if (mode === "confirm") {
907
1351
  hints = "y: confirm n: cancel";
908
1352
  } else if (mode === "upload") {
909
- hints = "Enter: upload path Esc: cancel";
1353
+ hints = "Enter: upload Esc: cancel";
1354
+ } else if (mode === "command") {
1355
+ hints = "Enter: run Esc: cancel";
910
1356
  } else if (mode === "rename") {
911
1357
  hints = "Enter: rename Esc: cancel";
1358
+ } else if (mode === "command-output") {
1359
+ hints = "j/k: scroll Enter/Esc: close";
1360
+ } else {
1361
+ hints = focusPane === "local"
1362
+ ? "u:upload s:sync n:rename x:delete /:filter Tab:switch f:Commands ?:help q:quit"
1363
+ : "Space:select a:all t:trash d:delete /:filter Tab:switch f:Commands ?:help q:quit";
912
1364
  }
913
1365
 
1366
+ const headerRight = selectedCount > 0
1367
+ ? `${selectedCount} selected`
1368
+ : "";
1369
+
914
1370
  return h(
915
1371
  Box,
916
1372
  { flexDirection: "column" },
917
- h(Text, { color: "cyan", bold: true }, truncate("Aethel", width)),
918
- account
919
- ? h(
920
- Text,
921
- null,
922
- truncate(
923
- `${account.name} <${account.email}> | ${account.usage} / ${account.limit}`,
924
- width
925
- )
926
- )
927
- : null,
928
1373
  h(
929
- Text,
930
- { dimColor: true },
931
- truncate(
932
- `${driveBreadcrumb} | ${localBreadcrumb}`,
933
- width
934
- )
935
- ),
936
- h(
937
- Text,
938
- { dimColor: true },
939
- truncate(
940
- `Legend: [MY ] owned by me [SHR] shared with me [DRV] shared drive [LOC] local item | ${selectedCount} Drive item(s) selected | Upload ${currentRemoteFolderWritable ? "enabled" : "blocked"}`,
941
- width
942
- )
1374
+ Box,
1375
+ { justifyContent: "space-between" },
1376
+ h(
1377
+ Text,
1378
+ { color: "cyan", bold: true },
1379
+ truncate(
1380
+ account ? `Aethel ${account.email} ${account.usage}/${account.limit}` : "Aethel",
1381
+ width - headerRight.length - 2
1382
+ )
1383
+ ),
1384
+ headerRight
1385
+ ? h(Text, { color: "yellow" }, headerRight)
1386
+ : null
943
1387
  ),
944
- h(Text, { dimColor: true }, "─".repeat(Math.max(Math.min(width, 80), 10))),
945
1388
  h(
946
1389
  Box,
947
1390
  { flexDirection: "row" },
@@ -989,7 +1432,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
989
1432
  ? h(
990
1433
  Box,
991
1434
  { flexDirection: "column" },
992
- h(Text, { color: "cyan" }, "Local path to upload into current Drive directory:"),
1435
+ h(Text, { color: "cyan" }, "Local path to upload:"),
993
1436
  h(TextInput, {
994
1437
  value: inputValue,
995
1438
  onChange: setInputValue,
@@ -1003,7 +1446,7 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
1003
1446
  ? h(
1004
1447
  Box,
1005
1448
  { flexDirection: "column" },
1006
- h(Text, { color: "cyan" }, "New local name:"),
1449
+ h(Text, { color: "cyan" }, "New name:"),
1007
1450
  h(TextInput, {
1008
1451
  value: inputValue,
1009
1452
  onChange: setInputValue,
@@ -1019,7 +1462,24 @@ export function AethelTui({ drive, includeSharedDrives = false }) {
1019
1462
  })
1020
1463
  )
1021
1464
  : null,
1465
+ mode === "command"
1466
+ ? h(
1467
+ Box,
1468
+ { flexDirection: "column" },
1469
+ h(Text, { color: "cyan" }, "aethel "),
1470
+ h(TextInput, {
1471
+ value: inputValue,
1472
+ onChange: setInputValue,
1473
+ onSubmit: (value) => {
1474
+ void executeCliCommand(value);
1475
+ },
1476
+ })
1477
+ )
1478
+ : null,
1022
1479
  h(Text, { dimColor: true }, truncate(hints, width)),
1023
- mode === "help" ? renderHelp() : null
1480
+ mode === "help" ? renderHelp() : null,
1481
+ mode === "command-output" && commandResult
1482
+ ? renderCommandOutput(commandResult, width, height, commandScroll)
1483
+ : null
1024
1484
  );
1025
1485
  }