droid-patch 0.6.1 → 0.7.1
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/README.md +62 -0
- package/README.zh-CN.md +62 -0
- package/dist/{alias-DKVU8DM_.mjs → alias-Bhigcbue.mjs} +28 -9
- package/dist/alias-Bhigcbue.mjs.map +1 -0
- package/dist/cli.mjs +1889 -35
- package/dist/cli.mjs.map +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +3 -3
- package/dist/alias-DKVU8DM_.mjs.map +0 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as removeAlias, d as listAllMetadata, f as loadAliasMetadata, i as listAliases, l as createMetadata, m as patchDroid, n as createAlias, o as removeAliasesByFilter, p as saveAliasMetadata, r as createAliasForWrapper, t as clearAllAliases, u as formatPatches } from "./alias-
|
|
2
|
+
import { a as removeAlias, d as listAllMetadata, f as loadAliasMetadata, i as listAliases, l as createMetadata, m as patchDroid, n as createAlias, o as removeAliasesByFilter, p as saveAliasMetadata, r as createAliasForWrapper, t as clearAllAliases, u as formatPatches } from "./alias-Bhigcbue.mjs";
|
|
3
3
|
import bin from "tiny-bin";
|
|
4
4
|
import { styleText } from "node:util";
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -531,6 +531,45 @@ PROXY_PID=""
|
|
|
531
531
|
PORT_FILE="/tmp/droid-websearch-\$\$.port"
|
|
532
532
|
STANDALONE="${standalone ? "1" : "0"}"
|
|
533
533
|
|
|
534
|
+
# Passthrough for non-interactive/meta commands (avoid starting a proxy for help/version/etc)
|
|
535
|
+
should_passthrough() {
|
|
536
|
+
# Any help/version flags before "--"
|
|
537
|
+
for arg in "\$@"; do
|
|
538
|
+
if [ "\$arg" = "--" ]; then
|
|
539
|
+
break
|
|
540
|
+
fi
|
|
541
|
+
case "\$arg" in
|
|
542
|
+
--help|-h|--version|-V)
|
|
543
|
+
return 0
|
|
544
|
+
;;
|
|
545
|
+
esac
|
|
546
|
+
done
|
|
547
|
+
|
|
548
|
+
# Top-level command token
|
|
549
|
+
local end_opts=0
|
|
550
|
+
for arg in "\$@"; do
|
|
551
|
+
if [ "\$arg" = "--" ]; then
|
|
552
|
+
end_opts=1
|
|
553
|
+
continue
|
|
554
|
+
fi
|
|
555
|
+
if [ "\$end_opts" -eq 0 ] && [[ "\$arg" == -* ]]; then
|
|
556
|
+
continue
|
|
557
|
+
fi
|
|
558
|
+
case "\$arg" in
|
|
559
|
+
help|version|completion|completions|exec)
|
|
560
|
+
return 0
|
|
561
|
+
;;
|
|
562
|
+
esac
|
|
563
|
+
break
|
|
564
|
+
done
|
|
565
|
+
|
|
566
|
+
return 1
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if should_passthrough "\$@"; then
|
|
570
|
+
exec "\$DROID_BIN" "\$@"
|
|
571
|
+
fi
|
|
572
|
+
|
|
534
573
|
# Cleanup function - kill proxy when droid exits
|
|
535
574
|
cleanup() {
|
|
536
575
|
if [ -n "\$PROXY_PID" ] && kill -0 "\$PROXY_PID" 2>/dev/null; then
|
|
@@ -623,6 +662,1739 @@ async function createWebSearchUnifiedFiles(outputDir, droidPath, aliasName, apiB
|
|
|
623
662
|
};
|
|
624
663
|
}
|
|
625
664
|
|
|
665
|
+
//#endregion
|
|
666
|
+
//#region src/statusline-patch.ts
|
|
667
|
+
function generateStatuslineMonitorScript() {
|
|
668
|
+
return `#!/usr/bin/env node
|
|
669
|
+
/* Auto-generated by droid-patch --statusline */
|
|
670
|
+
|
|
671
|
+
const fs = require('fs');
|
|
672
|
+
const os = require('os');
|
|
673
|
+
const path = require('path');
|
|
674
|
+
const { spawn, spawnSync } = require('child_process');
|
|
675
|
+
|
|
676
|
+
// This monitor does NOT draw directly to the terminal. It emits newline-delimited
|
|
677
|
+
// statusline frames to stdout. A wrapper (PTY proxy) is responsible for rendering
|
|
678
|
+
// the latest frame on a reserved bottom row to avoid flicker.
|
|
679
|
+
|
|
680
|
+
const FACTORY_HOME = path.join(os.homedir(), '.factory');
|
|
681
|
+
|
|
682
|
+
const SESSIONS_ROOT = path.join(FACTORY_HOME, 'sessions');
|
|
683
|
+
const LOG_PATH = path.join(FACTORY_HOME, 'logs', 'droid-log-single.log');
|
|
684
|
+
const CONFIG_PATH = path.join(FACTORY_HOME, 'config.json');
|
|
685
|
+
const GLOBAL_SETTINGS_PATH = path.join(FACTORY_HOME, 'settings.json');
|
|
686
|
+
|
|
687
|
+
const IS_APPLE_TERMINAL = process.env.TERM_PROGRAM === 'Apple_Terminal';
|
|
688
|
+
const MIN_RENDER_INTERVAL_MS = IS_APPLE_TERMINAL ? 1000 : 500;
|
|
689
|
+
|
|
690
|
+
const START_MS = Date.now();
|
|
691
|
+
const ARGS = process.argv.slice(2);
|
|
692
|
+
const PGID = Number(process.env.DROID_STATUSLINE_PGID || '');
|
|
693
|
+
|
|
694
|
+
function sleep(ms) {
|
|
695
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function isPositiveInt(n) {
|
|
699
|
+
return Number.isFinite(n) && n > 0;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function firstNonNull(promises) {
|
|
703
|
+
const list = Array.isArray(promises) ? promises : [];
|
|
704
|
+
if (list.length === 0) return Promise.resolve(null);
|
|
705
|
+
return new Promise((resolve) => {
|
|
706
|
+
let pending = list.length;
|
|
707
|
+
let done = false;
|
|
708
|
+
for (const p of list) {
|
|
709
|
+
Promise.resolve(p)
|
|
710
|
+
.then((value) => {
|
|
711
|
+
if (done) return;
|
|
712
|
+
if (value) {
|
|
713
|
+
done = true;
|
|
714
|
+
resolve(value);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
pending -= 1;
|
|
718
|
+
if (pending <= 0) resolve(null);
|
|
719
|
+
})
|
|
720
|
+
.catch(() => {
|
|
721
|
+
if (done) return;
|
|
722
|
+
pending -= 1;
|
|
723
|
+
if (pending <= 0) resolve(null);
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function listPidsInProcessGroup(pgid) {
|
|
730
|
+
if (!isPositiveInt(pgid)) return [];
|
|
731
|
+
try {
|
|
732
|
+
const res = spawnSync('ps', ['-ax', '-o', 'pid=,pgid='], {
|
|
733
|
+
encoding: 'utf8',
|
|
734
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
735
|
+
timeout: 800,
|
|
736
|
+
});
|
|
737
|
+
if (!res || res.status !== 0) return [];
|
|
738
|
+
const out = String(res.stdout || '');
|
|
739
|
+
const pids = [];
|
|
740
|
+
for (const line of out.split('\\n')) {
|
|
741
|
+
const parts = line.trim().split(/\\s+/);
|
|
742
|
+
if (parts.length < 2) continue;
|
|
743
|
+
const pid = Number(parts[0]);
|
|
744
|
+
const g = Number(parts[1]);
|
|
745
|
+
if (Number.isFinite(pid) && g === pgid) pids.push(pid);
|
|
746
|
+
}
|
|
747
|
+
return pids;
|
|
748
|
+
} catch {
|
|
749
|
+
return [];
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function resolveOpenSessionFromPids(pids) {
|
|
754
|
+
if (!Array.isArray(pids) || pids.length === 0) return null;
|
|
755
|
+
// lsof prints file names as lines prefixed with "n" when using -Fn
|
|
756
|
+
try {
|
|
757
|
+
const res = spawnSync('lsof', ['-p', pids.join(','), '-Fn'], {
|
|
758
|
+
encoding: 'utf8',
|
|
759
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
760
|
+
timeout: 1200,
|
|
761
|
+
});
|
|
762
|
+
if (!res || res.status !== 0) return null;
|
|
763
|
+
const out = String(res.stdout || '');
|
|
764
|
+
for (const line of out.split('\\n')) {
|
|
765
|
+
if (!line || line[0] !== 'n') continue;
|
|
766
|
+
const name = line.slice(1);
|
|
767
|
+
if (!name.startsWith(SESSIONS_ROOT + path.sep)) continue;
|
|
768
|
+
const m = name.match(/([0-9a-f-]{36})\\.(jsonl|settings\\.json)$/i);
|
|
769
|
+
if (!m) continue;
|
|
770
|
+
const id = m[1];
|
|
771
|
+
const workspaceDir = path.dirname(name);
|
|
772
|
+
if (path.dirname(workspaceDir) !== SESSIONS_ROOT) continue;
|
|
773
|
+
return { workspaceDir, id };
|
|
774
|
+
}
|
|
775
|
+
} catch {
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
async function resolveSessionFromProcessGroup(shouldAbort, maxTries = 20) {
|
|
782
|
+
if (!isPositiveInt(PGID)) return null;
|
|
783
|
+
// Wait a little for droid to create/open the session files.
|
|
784
|
+
for (let i = 0; i < maxTries; i++) {
|
|
785
|
+
if (shouldAbort && shouldAbort()) return null;
|
|
786
|
+
const pids = listPidsInProcessGroup(PGID);
|
|
787
|
+
const found = resolveOpenSessionFromPids(pids);
|
|
788
|
+
if (found) return found;
|
|
789
|
+
await sleep(100);
|
|
790
|
+
}
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function safeReadFile(filePath) {
|
|
795
|
+
try {
|
|
796
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
797
|
+
} catch {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function safeJsonParse(text) {
|
|
803
|
+
if (!text) return null;
|
|
804
|
+
try {
|
|
805
|
+
// Factory settings/config files can contain comments. Strip them safely without
|
|
806
|
+
// breaking URLs like "http://..." which contain "//" inside strings.
|
|
807
|
+
const stripComments = (input) => {
|
|
808
|
+
let out = '';
|
|
809
|
+
let inString = false;
|
|
810
|
+
let escape = false;
|
|
811
|
+
for (let i = 0; i < input.length; i++) {
|
|
812
|
+
const ch = input[i];
|
|
813
|
+
const next = input[i + 1];
|
|
814
|
+
|
|
815
|
+
if (inString) {
|
|
816
|
+
out += ch;
|
|
817
|
+
if (escape) {
|
|
818
|
+
escape = false;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (ch === '\\\\') {
|
|
822
|
+
escape = true;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (ch === '"') {
|
|
826
|
+
inString = false;
|
|
827
|
+
}
|
|
828
|
+
continue;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (ch === '"') {
|
|
832
|
+
inString = true;
|
|
833
|
+
out += ch;
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Line comment
|
|
838
|
+
if (ch === '/' && next === '/') {
|
|
839
|
+
while (i < input.length && input[i] !== '\\n') i++;
|
|
840
|
+
out += '\\n';
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Block comment
|
|
845
|
+
if (ch === '/' && next === '*') {
|
|
846
|
+
i += 2;
|
|
847
|
+
while (i < input.length && !(input[i] === '*' && input[i + 1] === '/')) i++;
|
|
848
|
+
i += 1;
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
out += ch;
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
return JSON.parse(stripComments(text));
|
|
858
|
+
} catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function readJsonFile(filePath) {
|
|
864
|
+
return safeJsonParse(safeReadFile(filePath));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function isUuid(text) {
|
|
868
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(text);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function parseResume(args) {
|
|
872
|
+
for (let i = 0; i < args.length; i++) {
|
|
873
|
+
const a = args[i];
|
|
874
|
+
if (a === '-r' || a === '--resume') {
|
|
875
|
+
const next = args[i + 1];
|
|
876
|
+
if (next && isUuid(next)) return { resumeFlag: true, resumeId: next };
|
|
877
|
+
return { resumeFlag: true, resumeId: null };
|
|
878
|
+
}
|
|
879
|
+
if (a.startsWith('--resume=')) {
|
|
880
|
+
const value = a.slice('--resume='.length);
|
|
881
|
+
return { resumeFlag: true, resumeId: isUuid(value) ? value : null };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
return { resumeFlag: false, resumeId: null };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function sanitizeWorkspaceDirName(cwd) {
|
|
888
|
+
return String(cwd)
|
|
889
|
+
.replace(/[:]/g, '')
|
|
890
|
+
.replace(/[\\\\/]/g, '-')
|
|
891
|
+
.replace(/\\s+/g, '-');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function listSessionCandidates(workspaceDir) {
|
|
895
|
+
let files = [];
|
|
896
|
+
try {
|
|
897
|
+
files = fs.readdirSync(workspaceDir);
|
|
898
|
+
} catch {
|
|
899
|
+
return [];
|
|
900
|
+
}
|
|
901
|
+
const candidates = [];
|
|
902
|
+
for (const file of files) {
|
|
903
|
+
const m = file.match(/^([0-9a-f-]{36})\\.(jsonl|settings\\.json)$/i);
|
|
904
|
+
if (!m) continue;
|
|
905
|
+
const id = m[1];
|
|
906
|
+
const fullPath = path.join(workspaceDir, file);
|
|
907
|
+
try {
|
|
908
|
+
const stat = fs.statSync(fullPath);
|
|
909
|
+
candidates.push({ id, fullPath, mtimeMs: stat.mtimeMs });
|
|
910
|
+
} catch {
|
|
911
|
+
// ignore
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return candidates;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function findWorkspaceDirForSessionId(workspaceDirs, sessionId) {
|
|
918
|
+
for (const dir of workspaceDirs) {
|
|
919
|
+
try {
|
|
920
|
+
const settingsPath = path.join(dir, sessionId + '.settings.json');
|
|
921
|
+
if (fs.existsSync(settingsPath)) return dir;
|
|
922
|
+
} catch {
|
|
923
|
+
// ignore
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function pickLatestSessionAcross(workspaceDirs) {
|
|
930
|
+
let best = null;
|
|
931
|
+
for (const dir of workspaceDirs) {
|
|
932
|
+
const candidates = listSessionCandidates(dir);
|
|
933
|
+
for (const c of candidates) {
|
|
934
|
+
if (!best || c.mtimeMs > best.mtimeMs) {
|
|
935
|
+
best = { workspaceDir: dir, id: c.id, mtimeMs: c.mtimeMs };
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
return best ? { workspaceDir: best.workspaceDir, id: best.id } : null;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async function waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, startMs, shouldAbort) {
|
|
943
|
+
for (let i = 0; i < 80; i++) {
|
|
944
|
+
if (shouldAbort && shouldAbort()) return null;
|
|
945
|
+
let best = null;
|
|
946
|
+
for (const dir of workspaceDirs) {
|
|
947
|
+
const known = knownIdsByWorkspace.get(dir) || new Set();
|
|
948
|
+
const candidates = listSessionCandidates(dir);
|
|
949
|
+
for (const c of candidates) {
|
|
950
|
+
if (!(c.mtimeMs >= startMs - 50 || !known.has(c.id))) continue;
|
|
951
|
+
if (!best || c.mtimeMs > best.mtimeMs) {
|
|
952
|
+
best = { workspaceDir: dir, id: c.id, mtimeMs: c.mtimeMs };
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
if (best?.id) return { workspaceDir: best.workspaceDir, id: best.id };
|
|
957
|
+
await sleep(100);
|
|
958
|
+
}
|
|
959
|
+
return null;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function safeRealpath(p) {
|
|
963
|
+
try {
|
|
964
|
+
return fs.realpathSync(p);
|
|
965
|
+
} catch {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function resolveWorkspaceDirs(cwd) {
|
|
971
|
+
const logical = cwd;
|
|
972
|
+
const real = safeRealpath(cwd);
|
|
973
|
+
const dirs = [];
|
|
974
|
+
for (const value of [logical, real]) {
|
|
975
|
+
if (!value || typeof value !== 'string') continue;
|
|
976
|
+
dirs.push(path.join(SESSIONS_ROOT, sanitizeWorkspaceDirName(value)));
|
|
977
|
+
}
|
|
978
|
+
return Array.from(new Set(dirs));
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function resolveSessionSettings(workspaceDir, sessionId) {
|
|
982
|
+
const settingsPath = path.join(workspaceDir, sessionId + '.settings.json');
|
|
983
|
+
const settings = readJsonFile(settingsPath) || {};
|
|
984
|
+
return { settingsPath, settings };
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function resolveGlobalSettingsModel() {
|
|
988
|
+
const global = readJsonFile(GLOBAL_SETTINGS_PATH);
|
|
989
|
+
return global && typeof global.model === 'string' ? global.model : null;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function resolveCustomModelIndex(modelId) {
|
|
993
|
+
if (typeof modelId !== 'string') return null;
|
|
994
|
+
if (!modelId.startsWith('custom:')) return null;
|
|
995
|
+
const m = modelId.match(/-(\\d+)$/);
|
|
996
|
+
if (!m) return null;
|
|
997
|
+
const idx = Number(m[1]);
|
|
998
|
+
return Number.isFinite(idx) ? idx : null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function resolveUnderlyingModelId(modelId, factoryConfig) {
|
|
1002
|
+
const idx = resolveCustomModelIndex(modelId);
|
|
1003
|
+
if (idx == null) return modelId;
|
|
1004
|
+
const entry = factoryConfig?.custom_models?.[idx];
|
|
1005
|
+
if (entry && typeof entry.model === 'string') return entry.model;
|
|
1006
|
+
return modelId;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function resolveProvider(modelId, factoryConfig) {
|
|
1010
|
+
const idx = resolveCustomModelIndex(modelId);
|
|
1011
|
+
if (idx != null) {
|
|
1012
|
+
const entry = factoryConfig?.custom_models?.[idx];
|
|
1013
|
+
if (entry && typeof entry.provider === 'string') return entry.provider;
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof modelId === 'string' && modelId.startsWith('claude-')) return 'anthropic';
|
|
1016
|
+
return '';
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function formatInt(n) {
|
|
1020
|
+
if (!Number.isFinite(n)) return '0';
|
|
1021
|
+
return Math.round(n).toString();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function formatTokens(n) {
|
|
1025
|
+
if (!Number.isFinite(n)) return '0';
|
|
1026
|
+
const sign = n < 0 ? '-' : '';
|
|
1027
|
+
const abs = Math.abs(n);
|
|
1028
|
+
if (abs >= 1_000_000) {
|
|
1029
|
+
const v = abs / 1_000_000;
|
|
1030
|
+
const s = v >= 10 ? v.toFixed(0) : v.toFixed(1);
|
|
1031
|
+
return sign + s.replace(/\\.0$/, '') + 'M';
|
|
1032
|
+
}
|
|
1033
|
+
if (abs >= 10_000) {
|
|
1034
|
+
const v = abs / 1_000;
|
|
1035
|
+
const s = v >= 100 ? v.toFixed(0) : v.toFixed(1);
|
|
1036
|
+
return sign + s.replace(/\\.0$/, '') + 'k';
|
|
1037
|
+
}
|
|
1038
|
+
return sign + Math.round(abs).toString();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function emitFrame(line) {
|
|
1042
|
+
try {
|
|
1043
|
+
process.stdout.write(String(line || '') + '\\n');
|
|
1044
|
+
} catch {
|
|
1045
|
+
// ignore
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function seg(bg, fg, text) {
|
|
1050
|
+
if (!text) return '';
|
|
1051
|
+
return '\\x1b[48;5;' + bg + 'm' + '\\x1b[38;5;' + fg + 'm' + ' ' + text + ' ' + '\\x1b[0m';
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function resolveGitBranch(cwd) {
|
|
1055
|
+
try {
|
|
1056
|
+
const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
1057
|
+
cwd,
|
|
1058
|
+
encoding: 'utf8',
|
|
1059
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1060
|
+
timeout: 800,
|
|
1061
|
+
});
|
|
1062
|
+
if (res && res.status === 0) {
|
|
1063
|
+
const branch = String(res.stdout || '').trim();
|
|
1064
|
+
if (branch && branch !== 'HEAD') return branch;
|
|
1065
|
+
}
|
|
1066
|
+
} catch {}
|
|
1067
|
+
try {
|
|
1068
|
+
const headPath = path.join(cwd, '.git', 'HEAD');
|
|
1069
|
+
const head = safeReadFile(headPath);
|
|
1070
|
+
if (head && head.startsWith('ref: ')) {
|
|
1071
|
+
const ref = head.slice('ref: '.length).trim();
|
|
1072
|
+
const m = ref.match(/refs\\/heads\\/(.+)$/);
|
|
1073
|
+
if (m) return m[1];
|
|
1074
|
+
}
|
|
1075
|
+
} catch {}
|
|
1076
|
+
return '';
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function resolveGitDiffSummary(cwd) {
|
|
1080
|
+
try {
|
|
1081
|
+
const res = spawnSync('git', ['diff', '--shortstat'], {
|
|
1082
|
+
cwd,
|
|
1083
|
+
encoding: 'utf8',
|
|
1084
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1085
|
+
timeout: 800,
|
|
1086
|
+
});
|
|
1087
|
+
if (!res || res.status !== 0) return '';
|
|
1088
|
+
const text = String(res.stdout || '').trim();
|
|
1089
|
+
if (!text) return '';
|
|
1090
|
+
const ins = (text.match(/(\\d+)\\sinsertions?\\(\\+\\)/) || [])[1];
|
|
1091
|
+
const del = (text.match(/(\\d+)\\sdeletions?\\(-\\)/) || [])[1];
|
|
1092
|
+
const i = ins ? Number(ins) : 0;
|
|
1093
|
+
const d = del ? Number(del) : 0;
|
|
1094
|
+
if (!Number.isFinite(i) && !Number.isFinite(d)) return '';
|
|
1095
|
+
if (i === 0 && d === 0) return '';
|
|
1096
|
+
return '(+' + formatInt(i) + ',-' + formatInt(d) + ')';
|
|
1097
|
+
} catch {
|
|
1098
|
+
return '';
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function buildLine(params) {
|
|
1103
|
+
const {
|
|
1104
|
+
provider,
|
|
1105
|
+
model,
|
|
1106
|
+
cwdBase,
|
|
1107
|
+
gitBranch,
|
|
1108
|
+
gitDiff,
|
|
1109
|
+
usedTokens,
|
|
1110
|
+
cacheRead,
|
|
1111
|
+
deltaInput,
|
|
1112
|
+
lastOutputTokens,
|
|
1113
|
+
sessionUsage,
|
|
1114
|
+
compacting,
|
|
1115
|
+
} = params;
|
|
1116
|
+
|
|
1117
|
+
let ctxPart = 'Ctx: ' + formatTokens(usedTokens);
|
|
1118
|
+
|
|
1119
|
+
const cachePart =
|
|
1120
|
+
cacheRead > 0 || deltaInput > 0
|
|
1121
|
+
? ' c' + formatTokens(cacheRead) + '+n' + formatTokens(deltaInput)
|
|
1122
|
+
: '';
|
|
1123
|
+
|
|
1124
|
+
const compactPart = compacting ? ' COMPACT' : '';
|
|
1125
|
+
|
|
1126
|
+
const usagePart = (() => {
|
|
1127
|
+
const u = sessionUsage || {};
|
|
1128
|
+
const input = Number(u.inputTokens ?? 0);
|
|
1129
|
+
const output = Number(u.outputTokens ?? 0);
|
|
1130
|
+
const cacheCreation = Number(u.cacheCreationTokens ?? 0);
|
|
1131
|
+
const cacheReadTotal = Number(u.cacheReadTokens ?? 0);
|
|
1132
|
+
const thinking = Number(u.thinkingTokens ?? 0);
|
|
1133
|
+
if (!(input || output || cacheCreation || cacheReadTotal || thinking)) return '';
|
|
1134
|
+
const parts = [];
|
|
1135
|
+
if (input) parts.push('In:' + formatTokens(input));
|
|
1136
|
+
if (output) parts.push('Out:' + formatTokens(output));
|
|
1137
|
+
if (cacheCreation) parts.push('Cre:' + formatTokens(cacheCreation));
|
|
1138
|
+
if (cacheReadTotal) parts.push('Read:' + formatTokens(cacheReadTotal));
|
|
1139
|
+
if (thinking) parts.push('Think:' + formatTokens(thinking));
|
|
1140
|
+
if (lastOutputTokens > 0) parts.push('LastOut:' + formatTokens(lastOutputTokens));
|
|
1141
|
+
return parts.join(' ');
|
|
1142
|
+
})();
|
|
1143
|
+
|
|
1144
|
+
const modelPart = model ? 'Model: ' + model : '';
|
|
1145
|
+
const providerPart = provider ? 'Prov: ' + provider : '';
|
|
1146
|
+
const cwdPart = cwdBase ? 'cwd: ' + cwdBase : '';
|
|
1147
|
+
const branchPart = gitBranch ? '\\uE0A0 ' + gitBranch : '';
|
|
1148
|
+
const diffPart = gitDiff || '';
|
|
1149
|
+
|
|
1150
|
+
// Background segments (powerline-like blocks)
|
|
1151
|
+
const sModel = seg(88, 15, modelPart); // dark red
|
|
1152
|
+
const sProvider = seg(160, 15, providerPart); // red
|
|
1153
|
+
const sCtx = seg(220, 0, ctxPart + (cachePart ? ' (' + cachePart.trim() + ')' : '')); // yellow
|
|
1154
|
+
const sUsage = seg(173, 0, usagePart); // orange-ish
|
|
1155
|
+
const sBranch = seg(24, 15, branchPart); // blue
|
|
1156
|
+
const sDiff = seg(34, 0, diffPart); // green
|
|
1157
|
+
const sCwd = seg(238, 15, cwdPart); // gray
|
|
1158
|
+
const sExtra = seg(99, 15, compactPart.trim()); // purple-ish
|
|
1159
|
+
|
|
1160
|
+
return [sModel, sProvider, sCtx, sUsage, sBranch, sDiff, sCwd, sExtra].filter(Boolean).join('');
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
async function main() {
|
|
1164
|
+
const factoryConfig = readJsonFile(CONFIG_PATH) || {};
|
|
1165
|
+
|
|
1166
|
+
const cwd = process.cwd();
|
|
1167
|
+
const cwdBase = path.basename(cwd) || cwd;
|
|
1168
|
+
const workspaceDirs = resolveWorkspaceDirs(cwd);
|
|
1169
|
+
const knownIdsByWorkspace = new Map();
|
|
1170
|
+
for (const dir of workspaceDirs) {
|
|
1171
|
+
const set = new Set();
|
|
1172
|
+
for (const c of listSessionCandidates(dir)) set.add(c.id);
|
|
1173
|
+
knownIdsByWorkspace.set(dir, set);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
const { resumeFlag, resumeId } = parseResume(ARGS);
|
|
1177
|
+
|
|
1178
|
+
let sessionId = null;
|
|
1179
|
+
let workspaceDir = null;
|
|
1180
|
+
if (resumeId) {
|
|
1181
|
+
sessionId = resumeId;
|
|
1182
|
+
workspaceDir = findWorkspaceDirForSessionId(workspaceDirs, sessionId) || workspaceDirs[0] || null;
|
|
1183
|
+
} else {
|
|
1184
|
+
let abortResolve = false;
|
|
1185
|
+
const shouldAbort = () => abortResolve;
|
|
1186
|
+
|
|
1187
|
+
const byProcPromise = resolveSessionFromProcessGroup(shouldAbort, 20);
|
|
1188
|
+
|
|
1189
|
+
let picked = null;
|
|
1190
|
+
if (resumeFlag) {
|
|
1191
|
+
// For --resume without an explicit id, don't block startup too long on ps/lsof.
|
|
1192
|
+
// Prefer process-group resolution when it is fast; otherwise fall back to latest.
|
|
1193
|
+
picked = await Promise.race([
|
|
1194
|
+
byProcPromise,
|
|
1195
|
+
sleep(400).then(() => null),
|
|
1196
|
+
]);
|
|
1197
|
+
if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
|
|
1198
|
+
} else {
|
|
1199
|
+
const freshPromise = waitForNewSessionAcross(workspaceDirs, knownIdsByWorkspace, START_MS, shouldAbort);
|
|
1200
|
+
picked = await firstNonNull([byProcPromise, freshPromise]);
|
|
1201
|
+
if (!picked) picked = pickLatestSessionAcross(workspaceDirs);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
abortResolve = true;
|
|
1205
|
+
|
|
1206
|
+
sessionId = picked?.id || null;
|
|
1207
|
+
workspaceDir = picked?.workspaceDir || workspaceDirs[0] || null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (!sessionId || !workspaceDir) return;
|
|
1211
|
+
|
|
1212
|
+
const { settingsPath, settings } = resolveSessionSettings(workspaceDir, sessionId);
|
|
1213
|
+
const modelId =
|
|
1214
|
+
(settings && typeof settings.model === 'string' ? settings.model : null) || resolveGlobalSettingsModel();
|
|
1215
|
+
|
|
1216
|
+
const provider = resolveProvider(modelId, factoryConfig);
|
|
1217
|
+
const underlyingModel = resolveUnderlyingModelId(modelId, factoryConfig) || modelId || 'unknown';
|
|
1218
|
+
|
|
1219
|
+
let last = { cacheReadInputTokens: 0, contextCount: 0, outputTokens: 0 };
|
|
1220
|
+
let sessionUsage = settings && typeof settings.tokenUsage === 'object' && settings.tokenUsage ? settings.tokenUsage : {};
|
|
1221
|
+
let compacting = false;
|
|
1222
|
+
let lastRenderAt = 0;
|
|
1223
|
+
let lastRenderedLine = '';
|
|
1224
|
+
let gitBranch = '';
|
|
1225
|
+
let gitDiff = '';
|
|
1226
|
+
|
|
1227
|
+
function renderNow() {
|
|
1228
|
+
const usedTokens = (last.cacheReadInputTokens || 0) + (last.contextCount || 0);
|
|
1229
|
+
const line = buildLine({
|
|
1230
|
+
provider,
|
|
1231
|
+
model: underlyingModel,
|
|
1232
|
+
cwdBase,
|
|
1233
|
+
gitBranch,
|
|
1234
|
+
gitDiff,
|
|
1235
|
+
usedTokens,
|
|
1236
|
+
cacheRead: last.cacheReadInputTokens || 0,
|
|
1237
|
+
deltaInput: last.contextCount || 0,
|
|
1238
|
+
lastOutputTokens: last.outputTokens || 0,
|
|
1239
|
+
sessionUsage,
|
|
1240
|
+
compacting,
|
|
1241
|
+
});
|
|
1242
|
+
if (line !== lastRenderedLine) {
|
|
1243
|
+
lastRenderedLine = line;
|
|
1244
|
+
emitFrame(line);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// Initial render.
|
|
1249
|
+
renderNow();
|
|
1250
|
+
|
|
1251
|
+
// Resolve git info asynchronously so startup isn't blocked on large repos.
|
|
1252
|
+
setTimeout(() => {
|
|
1253
|
+
try {
|
|
1254
|
+
gitBranch = resolveGitBranch(cwd);
|
|
1255
|
+
gitDiff = resolveGitDiffSummary(cwd);
|
|
1256
|
+
renderNow();
|
|
1257
|
+
} catch {}
|
|
1258
|
+
}, 0).unref();
|
|
1259
|
+
|
|
1260
|
+
// Seed prompt-context usage from existing logs (important for resumed sessions).
|
|
1261
|
+
// Do this asynchronously to avoid delaying the first statusline frame.
|
|
1262
|
+
if (resumeFlag || resumeId) {
|
|
1263
|
+
setTimeout(() => {
|
|
1264
|
+
try {
|
|
1265
|
+
// Backward scan to find the most recent streaming-context entry for this session.
|
|
1266
|
+
// The log can be large and shared across multiple sessions.
|
|
1267
|
+
const MAX_SCAN_BYTES = 64 * 1024 * 1024; // 64 MiB
|
|
1268
|
+
const CHUNK_BYTES = 1024 * 1024; // 1 MiB
|
|
1269
|
+
|
|
1270
|
+
const fd = fs.openSync(LOG_PATH, 'r');
|
|
1271
|
+
try {
|
|
1272
|
+
const stat = fs.fstatSync(fd);
|
|
1273
|
+
const size = Number(stat?.size ?? 0);
|
|
1274
|
+
let pos = size;
|
|
1275
|
+
let scanned = 0;
|
|
1276
|
+
let remainder = '';
|
|
1277
|
+
let seeded = false;
|
|
1278
|
+
|
|
1279
|
+
while (pos > 0 && scanned < MAX_SCAN_BYTES && !seeded) {
|
|
1280
|
+
const readSize = Math.min(CHUNK_BYTES, pos);
|
|
1281
|
+
const start = pos - readSize;
|
|
1282
|
+
const buf = Buffer.alloc(readSize);
|
|
1283
|
+
fs.readSync(fd, buf, 0, readSize, start);
|
|
1284
|
+
pos = start;
|
|
1285
|
+
scanned += readSize;
|
|
1286
|
+
|
|
1287
|
+
let text = buf.toString('utf8') + remainder;
|
|
1288
|
+
let lines = String(text).split('\\n');
|
|
1289
|
+
remainder = lines.shift() || '';
|
|
1290
|
+
if (pos === 0 && remainder) {
|
|
1291
|
+
lines.unshift(remainder);
|
|
1292
|
+
remainder = '';
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1296
|
+
const line = String(lines[i] || '').trimEnd();
|
|
1297
|
+
if (!line) continue;
|
|
1298
|
+
if (!line.includes('Context:')) continue;
|
|
1299
|
+
if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
|
|
1300
|
+
if (!line.includes('[Agent] Streaming result')) continue;
|
|
1301
|
+
const ctxIndex = line.indexOf('Context: ');
|
|
1302
|
+
if (ctxIndex === -1) continue;
|
|
1303
|
+
const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
|
|
1304
|
+
let ctx;
|
|
1305
|
+
try {
|
|
1306
|
+
ctx = JSON.parse(jsonStr);
|
|
1307
|
+
} catch {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
|
|
1311
|
+
const contextCount = Number(ctx?.contextCount ?? 0);
|
|
1312
|
+
const out = Number(ctx?.outputTokens ?? 0);
|
|
1313
|
+
if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
|
|
1314
|
+
if (Number.isFinite(contextCount)) last.contextCount = contextCount;
|
|
1315
|
+
if (Number.isFinite(out)) last.outputTokens = out;
|
|
1316
|
+
seeded = true;
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (remainder.length > 8192) remainder = remainder.slice(-8192);
|
|
1321
|
+
}
|
|
1322
|
+
} finally {
|
|
1323
|
+
try {
|
|
1324
|
+
fs.closeSync(fd);
|
|
1325
|
+
} catch {}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
renderNow();
|
|
1329
|
+
} catch {
|
|
1330
|
+
// ignore
|
|
1331
|
+
}
|
|
1332
|
+
}, 0).unref();
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
// Watch session settings for autonomy/reasoning changes (cheap polling with mtime).
|
|
1336
|
+
let settingsMtimeMs = 0;
|
|
1337
|
+
setInterval(() => {
|
|
1338
|
+
try {
|
|
1339
|
+
const stat = fs.statSync(settingsPath);
|
|
1340
|
+
if (stat.mtimeMs === settingsMtimeMs) return;
|
|
1341
|
+
settingsMtimeMs = stat.mtimeMs;
|
|
1342
|
+
const next = readJsonFile(settingsPath) || {};
|
|
1343
|
+
|
|
1344
|
+
// Keep session token usage in sync (used by /status).
|
|
1345
|
+
if (next && typeof next.tokenUsage === 'object' && next.tokenUsage) {
|
|
1346
|
+
sessionUsage = next.tokenUsage;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
const now = Date.now();
|
|
1350
|
+
if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
|
|
1351
|
+
lastRenderAt = now;
|
|
1352
|
+
renderNow();
|
|
1353
|
+
}
|
|
1354
|
+
} catch {
|
|
1355
|
+
// ignore
|
|
1356
|
+
}
|
|
1357
|
+
}, 750).unref();
|
|
1358
|
+
|
|
1359
|
+
// Follow the Factory log and update based on session-scoped events.
|
|
1360
|
+
const tail = spawn('tail', ['-n', '0', '-F', LOG_PATH], {
|
|
1361
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
let buffer = '';
|
|
1365
|
+
tail.stdout.on('data', (chunk) => {
|
|
1366
|
+
buffer += String(chunk);
|
|
1367
|
+
while (true) {
|
|
1368
|
+
const idx = buffer.indexOf('\\n');
|
|
1369
|
+
if (idx === -1) break;
|
|
1370
|
+
const line = buffer.slice(0, idx).trimEnd();
|
|
1371
|
+
buffer = buffer.slice(idx + 1);
|
|
1372
|
+
|
|
1373
|
+
if (!line.includes('Context:')) continue;
|
|
1374
|
+
if (!line.includes('"sessionId":"' + sessionId + '"')) continue;
|
|
1375
|
+
|
|
1376
|
+
const ctxIndex = line.indexOf('Context: ');
|
|
1377
|
+
if (ctxIndex === -1) continue;
|
|
1378
|
+
const jsonStr = line.slice(ctxIndex + 'Context: '.length).trim();
|
|
1379
|
+
let ctx;
|
|
1380
|
+
try {
|
|
1381
|
+
ctx = JSON.parse(jsonStr);
|
|
1382
|
+
} catch {
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Streaming token usage (best source for current context usage).
|
|
1387
|
+
if (line.includes('[Agent] Streaming result')) {
|
|
1388
|
+
const cacheRead = Number(ctx?.cacheReadInputTokens ?? 0);
|
|
1389
|
+
const contextCount = Number(ctx?.contextCount ?? 0);
|
|
1390
|
+
const out = Number(ctx?.outputTokens ?? 0);
|
|
1391
|
+
if (Number.isFinite(cacheRead)) last.cacheReadInputTokens = cacheRead;
|
|
1392
|
+
if (Number.isFinite(contextCount)) last.contextCount = contextCount;
|
|
1393
|
+
if (Number.isFinite(out)) last.outputTokens = out;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Compaction state hint.
|
|
1397
|
+
if (line.includes('[Compaction] Start')) compacting = true;
|
|
1398
|
+
if (line.includes('[Compaction] End')) compacting = false;
|
|
1399
|
+
|
|
1400
|
+
const now = Date.now();
|
|
1401
|
+
if (now - lastRenderAt >= MIN_RENDER_INTERVAL_MS) {
|
|
1402
|
+
lastRenderAt = now;
|
|
1403
|
+
renderNow();
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
const stop = () => {
|
|
1409
|
+
try { tail.kill('SIGTERM'); } catch {}
|
|
1410
|
+
process.exit(0);
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1413
|
+
process.on('SIGTERM', stop);
|
|
1414
|
+
process.on('SIGINT', stop);
|
|
1415
|
+
process.on('SIGHUP', stop);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
main().catch(() => {});
|
|
1419
|
+
`;
|
|
1420
|
+
}
|
|
1421
|
+
function generateStatuslineWrapperScript(execTargetPath, monitorScriptPath) {
|
|
1422
|
+
return `#!/usr/bin/env python3
|
|
1423
|
+
# Droid with Statusline (PTY proxy)
|
|
1424
|
+
# Auto-generated by droid-patch --statusline
|
|
1425
|
+
#
|
|
1426
|
+
# Design goal (KISS + no flicker):
|
|
1427
|
+
# - droid draws into a child PTY sized to (terminal_rows - RESERVED_ROWS)
|
|
1428
|
+
# - this wrapper is the ONLY writer to the real terminal
|
|
1429
|
+
# - a Node monitor emits statusline frames to a pipe; wrapper renders the latest frame
|
|
1430
|
+
# onto the reserved bottom row (a stable "second footer line").
|
|
1431
|
+
|
|
1432
|
+
import os
|
|
1433
|
+
import pty
|
|
1434
|
+
import re
|
|
1435
|
+
import select
|
|
1436
|
+
import signal
|
|
1437
|
+
import struct
|
|
1438
|
+
import subprocess
|
|
1439
|
+
import sys
|
|
1440
|
+
import termios
|
|
1441
|
+
import time
|
|
1442
|
+
import tty
|
|
1443
|
+
import fcntl
|
|
1444
|
+
|
|
1445
|
+
EXEC_TARGET = ${JSON.stringify(execTargetPath)}
|
|
1446
|
+
STATUSLINE_MONITOR = ${JSON.stringify(monitorScriptPath)}
|
|
1447
|
+
|
|
1448
|
+
IS_APPLE_TERMINAL = os.environ.get("TERM_PROGRAM") == "Apple_Terminal"
|
|
1449
|
+
MIN_RENDER_INTERVAL_MS = 800 if IS_APPLE_TERMINAL else 400
|
|
1450
|
+
QUIET_MS = 50 # Reduced to prevent statusline disappearing
|
|
1451
|
+
FORCE_REPAINT_INTERVAL_MS = 2000 # Force repaint every 2 seconds
|
|
1452
|
+
RESERVED_ROWS = 1
|
|
1453
|
+
|
|
1454
|
+
BYPASS_FLAGS = {"--help", "-h", "--version", "-V"}
|
|
1455
|
+
BYPASS_COMMANDS = {"help", "version", "completion", "completions", "exec"}
|
|
1456
|
+
|
|
1457
|
+
def _should_passthrough(argv):
|
|
1458
|
+
# Any help/version flags before "--"
|
|
1459
|
+
for a in argv:
|
|
1460
|
+
if a == "--":
|
|
1461
|
+
break
|
|
1462
|
+
if a in BYPASS_FLAGS:
|
|
1463
|
+
return True
|
|
1464
|
+
|
|
1465
|
+
# Top-level command token
|
|
1466
|
+
end_opts = False
|
|
1467
|
+
cmd = None
|
|
1468
|
+
for a in argv:
|
|
1469
|
+
if a == "--":
|
|
1470
|
+
end_opts = True
|
|
1471
|
+
continue
|
|
1472
|
+
if (not end_opts) and a.startswith("-"):
|
|
1473
|
+
continue
|
|
1474
|
+
cmd = a
|
|
1475
|
+
break
|
|
1476
|
+
|
|
1477
|
+
return cmd in BYPASS_COMMANDS
|
|
1478
|
+
|
|
1479
|
+
def _exec_passthrough():
|
|
1480
|
+
try:
|
|
1481
|
+
os.execvp(EXEC_TARGET, [EXEC_TARGET] + sys.argv[1:])
|
|
1482
|
+
except Exception as e:
|
|
1483
|
+
sys.stderr.write(f"[statusline] passthrough failed: {e}\\n")
|
|
1484
|
+
sys.exit(1)
|
|
1485
|
+
|
|
1486
|
+
# Passthrough for non-interactive/meta commands (avoid clearing screen / PTY proxy)
|
|
1487
|
+
if (not sys.stdin.isatty()) or (not sys.stdout.isatty()) or _should_passthrough(sys.argv[1:]):
|
|
1488
|
+
_exec_passthrough()
|
|
1489
|
+
|
|
1490
|
+
ANSI_RE = re.compile(r"\\x1b\\[[0-9;]*m")
|
|
1491
|
+
RESET_SGR = "\\x1b[0m"
|
|
1492
|
+
|
|
1493
|
+
def _term_size():
|
|
1494
|
+
try:
|
|
1495
|
+
sz = os.get_terminal_size(sys.stdout.fileno())
|
|
1496
|
+
return int(sz.lines), int(sz.columns)
|
|
1497
|
+
except Exception:
|
|
1498
|
+
return 24, 80
|
|
1499
|
+
|
|
1500
|
+
def _set_winsize(fd: int, rows: int, cols: int) -> None:
|
|
1501
|
+
try:
|
|
1502
|
+
winsz = struct.pack("HHHH", max(1, rows), max(1, cols), 0, 0)
|
|
1503
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsz)
|
|
1504
|
+
except Exception:
|
|
1505
|
+
pass
|
|
1506
|
+
|
|
1507
|
+
def _visible_width(text: str) -> int:
|
|
1508
|
+
# Remove only SGR sequences; good enough for our generated segments.
|
|
1509
|
+
stripped = ANSI_RE.sub("", text)
|
|
1510
|
+
return len(stripped)
|
|
1511
|
+
|
|
1512
|
+
def _clamp_ansi(text: str, cols: int) -> str:
|
|
1513
|
+
if cols <= 0:
|
|
1514
|
+
return text
|
|
1515
|
+
# Avoid writing into the last column. Some terminals keep an implicit wrap-pending state
|
|
1516
|
+
# when the last column is filled, and the next printable character can trigger a scroll.
|
|
1517
|
+
cols = cols - 1 if cols > 1 else cols
|
|
1518
|
+
if cols < 10:
|
|
1519
|
+
return text
|
|
1520
|
+
visible = 0
|
|
1521
|
+
i = 0
|
|
1522
|
+
out = []
|
|
1523
|
+
while i < len(text):
|
|
1524
|
+
ch = text[i]
|
|
1525
|
+
if ch == "\\x1b":
|
|
1526
|
+
m = text.find("m", i)
|
|
1527
|
+
if m != -1:
|
|
1528
|
+
out.append(text[i : m + 1])
|
|
1529
|
+
i = m + 1
|
|
1530
|
+
continue
|
|
1531
|
+
out.append(ch)
|
|
1532
|
+
i += 1
|
|
1533
|
+
continue
|
|
1534
|
+
if visible >= cols:
|
|
1535
|
+
break
|
|
1536
|
+
out.append(ch)
|
|
1537
|
+
i += 1
|
|
1538
|
+
visible += 1
|
|
1539
|
+
if i < len(text) and cols >= 1:
|
|
1540
|
+
if visible >= cols:
|
|
1541
|
+
if out:
|
|
1542
|
+
out[-1] = "…"
|
|
1543
|
+
else:
|
|
1544
|
+
out.append("…")
|
|
1545
|
+
else:
|
|
1546
|
+
out.append("…")
|
|
1547
|
+
out.append("\\x1b[0m")
|
|
1548
|
+
return "".join(out)
|
|
1549
|
+
|
|
1550
|
+
def _split_segments(text: str):
|
|
1551
|
+
if not text:
|
|
1552
|
+
return []
|
|
1553
|
+
segments = []
|
|
1554
|
+
start = 0
|
|
1555
|
+
while True:
|
|
1556
|
+
idx = text.find(RESET_SGR, start)
|
|
1557
|
+
if idx == -1:
|
|
1558
|
+
tail = text[start:]
|
|
1559
|
+
if tail:
|
|
1560
|
+
segments.append(tail)
|
|
1561
|
+
break
|
|
1562
|
+
seg = text[start : idx + len(RESET_SGR)]
|
|
1563
|
+
if seg:
|
|
1564
|
+
segments.append(seg)
|
|
1565
|
+
start = idx + len(RESET_SGR)
|
|
1566
|
+
return segments
|
|
1567
|
+
|
|
1568
|
+
def _wrap_segments(segments, cols: int):
|
|
1569
|
+
if not segments:
|
|
1570
|
+
return [""]
|
|
1571
|
+
if cols <= 0:
|
|
1572
|
+
return ["".join(segments)]
|
|
1573
|
+
|
|
1574
|
+
lines = []
|
|
1575
|
+
cur = []
|
|
1576
|
+
cur_w = 0
|
|
1577
|
+
|
|
1578
|
+
for seg in segments:
|
|
1579
|
+
seg_w = _visible_width(seg)
|
|
1580
|
+
if seg_w <= 0:
|
|
1581
|
+
continue
|
|
1582
|
+
|
|
1583
|
+
if not cur:
|
|
1584
|
+
if seg_w > cols:
|
|
1585
|
+
seg = _clamp_ansi(seg, cols)
|
|
1586
|
+
seg_w = _visible_width(seg)
|
|
1587
|
+
cur = [seg]
|
|
1588
|
+
cur_w = seg_w
|
|
1589
|
+
continue
|
|
1590
|
+
|
|
1591
|
+
if cur_w + seg_w <= cols:
|
|
1592
|
+
cur.append(seg)
|
|
1593
|
+
cur_w += seg_w
|
|
1594
|
+
else:
|
|
1595
|
+
lines.append("".join(cur))
|
|
1596
|
+
if seg_w > cols:
|
|
1597
|
+
seg = _clamp_ansi(seg, cols)
|
|
1598
|
+
seg_w = _visible_width(seg)
|
|
1599
|
+
cur = [seg]
|
|
1600
|
+
cur_w = seg_w
|
|
1601
|
+
|
|
1602
|
+
if cur:
|
|
1603
|
+
lines.append("".join(cur))
|
|
1604
|
+
|
|
1605
|
+
return lines if lines else [""]
|
|
1606
|
+
|
|
1607
|
+
class StatusRenderer:
|
|
1608
|
+
def __init__(self):
|
|
1609
|
+
self._raw = ""
|
|
1610
|
+
self._segments = []
|
|
1611
|
+
self._lines = [""]
|
|
1612
|
+
self._active_reserved_rows = RESERVED_ROWS
|
|
1613
|
+
self._last_render_ms = 0
|
|
1614
|
+
self._last_child_out_ms = 0
|
|
1615
|
+
self._force_repaint = False
|
|
1616
|
+
self._urgent = False
|
|
1617
|
+
self._cursor_visible = True
|
|
1618
|
+
|
|
1619
|
+
def note_child_output(self):
|
|
1620
|
+
self._last_child_out_ms = int(time.time() * 1000)
|
|
1621
|
+
|
|
1622
|
+
def set_cursor_visible(self, visible: bool):
|
|
1623
|
+
self._cursor_visible = bool(visible)
|
|
1624
|
+
|
|
1625
|
+
def force_repaint(self, urgent: bool = False):
|
|
1626
|
+
self._force_repaint = True
|
|
1627
|
+
if urgent:
|
|
1628
|
+
self._urgent = True
|
|
1629
|
+
|
|
1630
|
+
def set_active_reserved_rows(self, reserved_rows: int):
|
|
1631
|
+
try:
|
|
1632
|
+
self._active_reserved_rows = max(1, int(reserved_rows or 1))
|
|
1633
|
+
except Exception:
|
|
1634
|
+
self._active_reserved_rows = 1
|
|
1635
|
+
|
|
1636
|
+
def set_line(self, line: str):
|
|
1637
|
+
if line != self._raw:
|
|
1638
|
+
self._raw = line
|
|
1639
|
+
self._segments = _split_segments(line)
|
|
1640
|
+
self._force_repaint = True
|
|
1641
|
+
|
|
1642
|
+
def _write(self, b: bytes) -> None:
|
|
1643
|
+
try:
|
|
1644
|
+
os.write(sys.stdout.fileno(), b)
|
|
1645
|
+
except Exception:
|
|
1646
|
+
pass
|
|
1647
|
+
|
|
1648
|
+
def desired_reserved_rows(self, physical_rows: int, cols: int, min_reserved: int):
|
|
1649
|
+
try:
|
|
1650
|
+
rows = int(physical_rows or 24)
|
|
1651
|
+
except Exception:
|
|
1652
|
+
rows = 24
|
|
1653
|
+
try:
|
|
1654
|
+
cols = int(cols or 80)
|
|
1655
|
+
except Exception:
|
|
1656
|
+
cols = 80
|
|
1657
|
+
|
|
1658
|
+
max_reserved = max(1, rows - 4)
|
|
1659
|
+
segments = self._segments if self._segments else ([self._raw] if self._raw else [])
|
|
1660
|
+
lines = _wrap_segments(segments, cols) if segments else [""]
|
|
1661
|
+
|
|
1662
|
+
needed = min(len(lines), max_reserved)
|
|
1663
|
+
desired = max(int(min_reserved or 1), needed)
|
|
1664
|
+
desired = min(desired, max_reserved)
|
|
1665
|
+
|
|
1666
|
+
if len(lines) < desired:
|
|
1667
|
+
lines = [""] * (desired - len(lines)) + lines
|
|
1668
|
+
if len(lines) > desired:
|
|
1669
|
+
lines = lines[-desired:]
|
|
1670
|
+
|
|
1671
|
+
self._lines = lines
|
|
1672
|
+
return desired
|
|
1673
|
+
|
|
1674
|
+
def clear_reserved_area(
|
|
1675
|
+
self,
|
|
1676
|
+
physical_rows: int,
|
|
1677
|
+
cols: int,
|
|
1678
|
+
reserved_rows: int,
|
|
1679
|
+
restore_row: int = 1,
|
|
1680
|
+
restore_col: int = 1,
|
|
1681
|
+
):
|
|
1682
|
+
try:
|
|
1683
|
+
rows = int(physical_rows or 24)
|
|
1684
|
+
except Exception:
|
|
1685
|
+
rows = 24
|
|
1686
|
+
try:
|
|
1687
|
+
cols = int(cols or 80)
|
|
1688
|
+
except Exception:
|
|
1689
|
+
cols = 80
|
|
1690
|
+
try:
|
|
1691
|
+
reserved = max(1, int(reserved_rows or 1))
|
|
1692
|
+
except Exception:
|
|
1693
|
+
reserved = 1
|
|
1694
|
+
|
|
1695
|
+
reserved = min(reserved, rows)
|
|
1696
|
+
start_row = rows - reserved + 1
|
|
1697
|
+
parts = ["\\x1b[?2026h", "\\x1b[?25l", RESET_SGR]
|
|
1698
|
+
for i in range(reserved):
|
|
1699
|
+
parts.append(f"\\x1b[{start_row + i};1H\\x1b[2K")
|
|
1700
|
+
parts.append(f"\\x1b[{restore_row};{restore_col}H")
|
|
1701
|
+
parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
|
|
1702
|
+
parts.append("\\x1b[?2026l")
|
|
1703
|
+
self._write("".join(parts).encode("utf-8", "ignore"))
|
|
1704
|
+
|
|
1705
|
+
def render(self, physical_rows: int, cols: int, restore_row: int = 1, restore_col: int = 1) -> None:
|
|
1706
|
+
now_ms = int(time.time() * 1000)
|
|
1707
|
+
if not self._force_repaint:
|
|
1708
|
+
return
|
|
1709
|
+
if not self._raw:
|
|
1710
|
+
self._force_repaint = False
|
|
1711
|
+
self._urgent = False
|
|
1712
|
+
return
|
|
1713
|
+
if (not self._urgent) and (now_ms - self._last_render_ms < MIN_RENDER_INTERVAL_MS):
|
|
1714
|
+
return
|
|
1715
|
+
# Avoid repainting while child is actively writing (reduces flicker on macOS Terminal).
|
|
1716
|
+
if (not self._urgent) and (QUIET_MS > 0 and now_ms - self._last_child_out_ms < QUIET_MS):
|
|
1717
|
+
return
|
|
1718
|
+
|
|
1719
|
+
try:
|
|
1720
|
+
rows = int(physical_rows or 24)
|
|
1721
|
+
except Exception:
|
|
1722
|
+
rows = 24
|
|
1723
|
+
try:
|
|
1724
|
+
cols = int(cols or 80)
|
|
1725
|
+
except Exception:
|
|
1726
|
+
cols = 80
|
|
1727
|
+
|
|
1728
|
+
if cols <= 0:
|
|
1729
|
+
cols = 80
|
|
1730
|
+
|
|
1731
|
+
reserved = max(1, min(self._active_reserved_rows, max(1, rows - 4)))
|
|
1732
|
+
start_row = rows - reserved + 1
|
|
1733
|
+
|
|
1734
|
+
lines = self._lines or [""]
|
|
1735
|
+
if len(lines) < reserved:
|
|
1736
|
+
lines = [""] * (reserved - len(lines)) + lines
|
|
1737
|
+
if len(lines) > reserved:
|
|
1738
|
+
lines = lines[-reserved:]
|
|
1739
|
+
|
|
1740
|
+
child_rows = rows - reserved
|
|
1741
|
+
|
|
1742
|
+
parts = ["\\x1b[?2026h", "\\x1b[?25l"]
|
|
1743
|
+
# Always set scroll region to exclude statusline area
|
|
1744
|
+
parts.append(f"\\x1b[1;{child_rows}r")
|
|
1745
|
+
for i in range(reserved):
|
|
1746
|
+
row = start_row + i
|
|
1747
|
+
text = _clamp_ansi(lines[i], cols)
|
|
1748
|
+
parts.append(f"\\x1b[{row};1H{RESET_SGR}\\x1b[2K")
|
|
1749
|
+
parts.append(f"\\x1b[{row};1H{text}{RESET_SGR}")
|
|
1750
|
+
parts.append(f"\\x1b[{restore_row};{restore_col}H")
|
|
1751
|
+
parts.append("\\x1b[?25h" if self._cursor_visible else "\\x1b[?25l")
|
|
1752
|
+
parts.append("\\x1b[?2026l")
|
|
1753
|
+
|
|
1754
|
+
self._write("".join(parts).encode("utf-8", "ignore"))
|
|
1755
|
+
self._last_render_ms = now_ms
|
|
1756
|
+
self._force_repaint = False
|
|
1757
|
+
self._urgent = False
|
|
1758
|
+
|
|
1759
|
+
def clear(self):
|
|
1760
|
+
r, c = _term_size()
|
|
1761
|
+
self.clear_reserved_area(r, c, max(self._active_reserved_rows, RESERVED_ROWS))
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
class OutputRewriter:
|
|
1765
|
+
# Rewrite a small subset of ANSI cursor positioning commands to ensure the child UI never
|
|
1766
|
+
# draws into the reserved statusline rows.
|
|
1767
|
+
#
|
|
1768
|
+
# Key idea: many TUIs use "ESC[999;1H" to jump to the terminal bottom. If we forward that
|
|
1769
|
+
# unmodified, it targets the *physical* bottom row, overwriting our statusline. We clamp it
|
|
1770
|
+
# to "max_row" (physical_rows - reserved_rows) so the child's "bottom" becomes the line
|
|
1771
|
+
# just above the statusline.
|
|
1772
|
+
def __init__(self):
|
|
1773
|
+
self._buf = b""
|
|
1774
|
+
|
|
1775
|
+
def feed(self, chunk: bytes, max_row: int) -> bytes:
|
|
1776
|
+
if not chunk:
|
|
1777
|
+
return b""
|
|
1778
|
+
|
|
1779
|
+
data = self._buf + chunk
|
|
1780
|
+
self._buf = b""
|
|
1781
|
+
out = bytearray()
|
|
1782
|
+
n = len(data)
|
|
1783
|
+
i = 0
|
|
1784
|
+
|
|
1785
|
+
def _is_final_byte(v: int) -> bool:
|
|
1786
|
+
return 0x40 <= v <= 0x7E
|
|
1787
|
+
|
|
1788
|
+
while i < n:
|
|
1789
|
+
b = data[i]
|
|
1790
|
+
if b != 0x1B: # ESC
|
|
1791
|
+
out.append(b)
|
|
1792
|
+
i += 1
|
|
1793
|
+
continue
|
|
1794
|
+
|
|
1795
|
+
if i + 1 >= n:
|
|
1796
|
+
self._buf = data[i:]
|
|
1797
|
+
break
|
|
1798
|
+
|
|
1799
|
+
nxt = data[i + 1]
|
|
1800
|
+
if nxt != 0x5B: # not CSI
|
|
1801
|
+
out.append(b)
|
|
1802
|
+
i += 1
|
|
1803
|
+
continue
|
|
1804
|
+
|
|
1805
|
+
# CSI sequence: ESC [ ... <final>
|
|
1806
|
+
j = i + 2
|
|
1807
|
+
while j < n and not _is_final_byte(data[j]):
|
|
1808
|
+
j += 1
|
|
1809
|
+
if j >= n:
|
|
1810
|
+
self._buf = data[i:]
|
|
1811
|
+
break
|
|
1812
|
+
|
|
1813
|
+
final = data[j]
|
|
1814
|
+
seq = data[i : j + 1]
|
|
1815
|
+
|
|
1816
|
+
if final in (ord("H"), ord("f")) and max_row > 0:
|
|
1817
|
+
params = data[i + 2 : j]
|
|
1818
|
+
try:
|
|
1819
|
+
s = params.decode("ascii", "ignore")
|
|
1820
|
+
except Exception:
|
|
1821
|
+
s = ""
|
|
1822
|
+
|
|
1823
|
+
# Only handle the simple numeric form (no private/DEC prefixes like "?")
|
|
1824
|
+
if not s or s[0] in "0123456789;":
|
|
1825
|
+
parts = s.split(";") if s else []
|
|
1826
|
+
try:
|
|
1827
|
+
row = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
|
|
1828
|
+
except Exception:
|
|
1829
|
+
row = 1
|
|
1830
|
+
try:
|
|
1831
|
+
col = int(parts[1]) if (len(parts) >= 2 and parts[1]) else 1
|
|
1832
|
+
except Exception:
|
|
1833
|
+
col = 1
|
|
1834
|
+
|
|
1835
|
+
if row == 999 or row > max_row:
|
|
1836
|
+
row = max_row
|
|
1837
|
+
if row < 1:
|
|
1838
|
+
row = 1
|
|
1839
|
+
if col < 1:
|
|
1840
|
+
col = 1
|
|
1841
|
+
|
|
1842
|
+
new_params = f"{row};{col}".encode("ascii", "ignore")
|
|
1843
|
+
seq = b"\\x1b[" + new_params + bytes([final])
|
|
1844
|
+
|
|
1845
|
+
elif final == ord("r") and max_row > 0:
|
|
1846
|
+
# DECSTBM - Set scrolling region. If the child resets to the full physical screen
|
|
1847
|
+
# (e.g. ESC[r), the reserved statusline row becomes scrollable and our statusline
|
|
1848
|
+
# will "float" upward when the UI scrolls. Clamp bottom to max_row (child area).
|
|
1849
|
+
params = data[i + 2 : j]
|
|
1850
|
+
try:
|
|
1851
|
+
s = params.decode("ascii", "ignore")
|
|
1852
|
+
except Exception:
|
|
1853
|
+
s = ""
|
|
1854
|
+
|
|
1855
|
+
# Only handle the simple numeric form (no private/DEC prefixes like "?")
|
|
1856
|
+
if not s or s[0] in "0123456789;":
|
|
1857
|
+
parts = s.split(";") if s else []
|
|
1858
|
+
try:
|
|
1859
|
+
top = int(parts[0]) if (len(parts) >= 1 and parts[0]) else 1
|
|
1860
|
+
except Exception:
|
|
1861
|
+
top = 1
|
|
1862
|
+
try:
|
|
1863
|
+
bottom = int(parts[1]) if (len(parts) >= 2 and parts[1]) else max_row
|
|
1864
|
+
except Exception:
|
|
1865
|
+
bottom = max_row
|
|
1866
|
+
|
|
1867
|
+
if top <= 0:
|
|
1868
|
+
top = 1
|
|
1869
|
+
if bottom <= 0 or bottom == 999 or bottom > max_row:
|
|
1870
|
+
bottom = max_row
|
|
1871
|
+
if top > bottom:
|
|
1872
|
+
top = 1
|
|
1873
|
+
|
|
1874
|
+
seq = f"\\x1b[{top};{bottom}r".encode("ascii", "ignore")
|
|
1875
|
+
|
|
1876
|
+
out.extend(seq)
|
|
1877
|
+
i = j + 1
|
|
1878
|
+
|
|
1879
|
+
return bytes(out)
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
class CursorTracker:
|
|
1883
|
+
# Best-effort cursor tracking so the wrapper can restore the cursor position without using
|
|
1884
|
+
# ESC7/ESC8 (which droid/Ink also uses internally).
|
|
1885
|
+
def __init__(self):
|
|
1886
|
+
self.row = 1
|
|
1887
|
+
self.col = 1
|
|
1888
|
+
self._saved_row = 1
|
|
1889
|
+
self._saved_col = 1
|
|
1890
|
+
self._buf = b""
|
|
1891
|
+
self._in_osc = False
|
|
1892
|
+
self._utf8_cont = 0
|
|
1893
|
+
self._wrap_pending = False
|
|
1894
|
+
|
|
1895
|
+
def position(self):
|
|
1896
|
+
return self.row, self.col
|
|
1897
|
+
|
|
1898
|
+
def feed(self, chunk: bytes, max_row: int, max_col: int) -> None:
|
|
1899
|
+
if not chunk:
|
|
1900
|
+
return
|
|
1901
|
+
try:
|
|
1902
|
+
max_row = max(1, int(max_row or 1))
|
|
1903
|
+
except Exception:
|
|
1904
|
+
max_row = 1
|
|
1905
|
+
try:
|
|
1906
|
+
max_col = max(1, int(max_col or 1))
|
|
1907
|
+
except Exception:
|
|
1908
|
+
max_col = 1
|
|
1909
|
+
|
|
1910
|
+
data = self._buf + chunk
|
|
1911
|
+
self._buf = b""
|
|
1912
|
+
n = len(data)
|
|
1913
|
+
i = 0
|
|
1914
|
+
|
|
1915
|
+
def _clamp():
|
|
1916
|
+
if self.row < 1:
|
|
1917
|
+
self.row = 1
|
|
1918
|
+
elif self.row > max_row:
|
|
1919
|
+
self.row = max_row
|
|
1920
|
+
if self.col < 1:
|
|
1921
|
+
self.col = 1
|
|
1922
|
+
elif self.col > max_col:
|
|
1923
|
+
self.col = max_col
|
|
1924
|
+
|
|
1925
|
+
def _parse_int(v: str, default: int) -> int:
|
|
1926
|
+
try:
|
|
1927
|
+
return int(v) if v else default
|
|
1928
|
+
except Exception:
|
|
1929
|
+
return default
|
|
1930
|
+
|
|
1931
|
+
while i < n:
|
|
1932
|
+
b = data[i]
|
|
1933
|
+
|
|
1934
|
+
if self._in_osc:
|
|
1935
|
+
# OSC/DCS/etc are terminated by BEL or ST (ESC \\).
|
|
1936
|
+
if b == 0x07:
|
|
1937
|
+
self._in_osc = False
|
|
1938
|
+
i += 1
|
|
1939
|
+
continue
|
|
1940
|
+
if b == 0x1B:
|
|
1941
|
+
if i + 1 >= n:
|
|
1942
|
+
self._buf = data[i:]
|
|
1943
|
+
break
|
|
1944
|
+
if data[i + 1] == 0x5C:
|
|
1945
|
+
self._in_osc = False
|
|
1946
|
+
i += 2
|
|
1947
|
+
continue
|
|
1948
|
+
i += 1
|
|
1949
|
+
continue
|
|
1950
|
+
|
|
1951
|
+
if self._utf8_cont > 0:
|
|
1952
|
+
if 0x80 <= b <= 0xBF:
|
|
1953
|
+
self._utf8_cont -= 1
|
|
1954
|
+
i += 1
|
|
1955
|
+
continue
|
|
1956
|
+
self._utf8_cont = 0
|
|
1957
|
+
|
|
1958
|
+
if b == 0x1B: # ESC
|
|
1959
|
+
self._wrap_pending = False
|
|
1960
|
+
if i + 1 >= n:
|
|
1961
|
+
self._buf = data[i:]
|
|
1962
|
+
break
|
|
1963
|
+
nxt = data[i + 1]
|
|
1964
|
+
|
|
1965
|
+
if nxt == 0x5B: # CSI
|
|
1966
|
+
j = i + 2
|
|
1967
|
+
while j < n and not (0x40 <= data[j] <= 0x7E):
|
|
1968
|
+
j += 1
|
|
1969
|
+
if j >= n:
|
|
1970
|
+
self._buf = data[i:]
|
|
1971
|
+
break
|
|
1972
|
+
final = data[j]
|
|
1973
|
+
params = data[i + 2 : j]
|
|
1974
|
+
try:
|
|
1975
|
+
s = params.decode("ascii", "ignore")
|
|
1976
|
+
except Exception:
|
|
1977
|
+
s = ""
|
|
1978
|
+
|
|
1979
|
+
if s and s[0] not in "0123456789;":
|
|
1980
|
+
i = j + 1
|
|
1981
|
+
continue
|
|
1982
|
+
|
|
1983
|
+
parts = s.split(";") if s else []
|
|
1984
|
+
p0 = _parse_int(parts[0] if len(parts) >= 1 else "", 1)
|
|
1985
|
+
p1 = _parse_int(parts[1] if len(parts) >= 2 else "", 1)
|
|
1986
|
+
|
|
1987
|
+
if final in (ord("H"), ord("f")):
|
|
1988
|
+
self.row = p0
|
|
1989
|
+
self.col = p1
|
|
1990
|
+
_clamp()
|
|
1991
|
+
elif final == ord("A"):
|
|
1992
|
+
self.row = max(1, self.row - p0)
|
|
1993
|
+
elif final == ord("B"):
|
|
1994
|
+
self.row = min(max_row, self.row + p0)
|
|
1995
|
+
elif final == ord("C"):
|
|
1996
|
+
self.col = min(max_col, self.col + p0)
|
|
1997
|
+
elif final == ord("D"):
|
|
1998
|
+
self.col = max(1, self.col - p0)
|
|
1999
|
+
elif final == ord("E"):
|
|
2000
|
+
self.row = min(max_row, self.row + p0)
|
|
2001
|
+
self.col = 1
|
|
2002
|
+
elif final == ord("F"):
|
|
2003
|
+
self.row = max(1, self.row - p0)
|
|
2004
|
+
self.col = 1
|
|
2005
|
+
elif final == ord("G"):
|
|
2006
|
+
self.col = p0
|
|
2007
|
+
_clamp()
|
|
2008
|
+
elif final == ord("d"):
|
|
2009
|
+
self.row = p0
|
|
2010
|
+
_clamp()
|
|
2011
|
+
elif final == ord("r"):
|
|
2012
|
+
# DECSTBM moves the cursor to the home position.
|
|
2013
|
+
self.row = 1
|
|
2014
|
+
self.col = 1
|
|
2015
|
+
elif final == ord("s"):
|
|
2016
|
+
self._saved_row = self.row
|
|
2017
|
+
self._saved_col = self.col
|
|
2018
|
+
elif final == ord("u"):
|
|
2019
|
+
self.row = self._saved_row
|
|
2020
|
+
self.col = self._saved_col
|
|
2021
|
+
_clamp()
|
|
2022
|
+
|
|
2023
|
+
i = j + 1
|
|
2024
|
+
continue
|
|
2025
|
+
|
|
2026
|
+
# OSC, DCS, PM, APC, SOS (terminated by ST or BEL).
|
|
2027
|
+
if nxt == 0x5D or nxt in (0x50, 0x5E, 0x5F, 0x58):
|
|
2028
|
+
self._in_osc = True
|
|
2029
|
+
i += 2
|
|
2030
|
+
continue
|
|
2031
|
+
|
|
2032
|
+
# DECSC / DECRC
|
|
2033
|
+
if nxt == 0x37:
|
|
2034
|
+
self._saved_row = self.row
|
|
2035
|
+
self._saved_col = self.col
|
|
2036
|
+
i += 2
|
|
2037
|
+
continue
|
|
2038
|
+
if nxt == 0x38:
|
|
2039
|
+
self.row = self._saved_row
|
|
2040
|
+
self.col = self._saved_col
|
|
2041
|
+
_clamp()
|
|
2042
|
+
i += 2
|
|
2043
|
+
continue
|
|
2044
|
+
|
|
2045
|
+
# Other single-escape sequences (ignore).
|
|
2046
|
+
i += 2
|
|
2047
|
+
continue
|
|
2048
|
+
|
|
2049
|
+
if b == 0x0D: # CR
|
|
2050
|
+
self.col = 1
|
|
2051
|
+
self._wrap_pending = False
|
|
2052
|
+
i += 1
|
|
2053
|
+
continue
|
|
2054
|
+
if b in (0x0A, 0x0B, 0x0C): # LF/VT/FF
|
|
2055
|
+
self.row = min(max_row, self.row + 1)
|
|
2056
|
+
self._wrap_pending = False
|
|
2057
|
+
i += 1
|
|
2058
|
+
continue
|
|
2059
|
+
if b == 0x08: # BS
|
|
2060
|
+
self.col = max(1, self.col - 1)
|
|
2061
|
+
self._wrap_pending = False
|
|
2062
|
+
i += 1
|
|
2063
|
+
continue
|
|
2064
|
+
if b == 0x09: # TAB
|
|
2065
|
+
next_stop = ((self.col - 1) // 8 + 1) * 8 + 1
|
|
2066
|
+
self.col = min(max_col, next_stop)
|
|
2067
|
+
self._wrap_pending = False
|
|
2068
|
+
i += 1
|
|
2069
|
+
continue
|
|
2070
|
+
|
|
2071
|
+
if b < 0x20 or b == 0x7F:
|
|
2072
|
+
i += 1
|
|
2073
|
+
continue
|
|
2074
|
+
|
|
2075
|
+
# Printable characters.
|
|
2076
|
+
if self._wrap_pending:
|
|
2077
|
+
self.row = min(max_row, self.row + 1)
|
|
2078
|
+
self.col = 1
|
|
2079
|
+
self._wrap_pending = False
|
|
2080
|
+
|
|
2081
|
+
if b >= 0x80:
|
|
2082
|
+
if (b & 0xE0) == 0xC0:
|
|
2083
|
+
self._utf8_cont = 1
|
|
2084
|
+
elif (b & 0xF0) == 0xE0:
|
|
2085
|
+
self._utf8_cont = 2
|
|
2086
|
+
elif (b & 0xF8) == 0xF0:
|
|
2087
|
+
self._utf8_cont = 3
|
|
2088
|
+
else:
|
|
2089
|
+
self._utf8_cont = 0
|
|
2090
|
+
|
|
2091
|
+
if self.col < max_col:
|
|
2092
|
+
self.col += 1
|
|
2093
|
+
else:
|
|
2094
|
+
self.col = max_col
|
|
2095
|
+
self._wrap_pending = True
|
|
2096
|
+
|
|
2097
|
+
i += 1
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
def main():
|
|
2101
|
+
# Start from a clean viewport. Droid's TUI assumes a fresh screen; without this,
|
|
2102
|
+
# it can visually mix with prior shell output (especially when scrollback exists).
|
|
2103
|
+
try:
|
|
2104
|
+
os.write(sys.stdout.fileno(), b"\\x1b[?2026h\\x1b[0m\\x1b[r\\x1b[2J\\x1b[H\\x1b[?2026l")
|
|
2105
|
+
except Exception:
|
|
2106
|
+
pass
|
|
2107
|
+
|
|
2108
|
+
renderer = StatusRenderer()
|
|
2109
|
+
renderer.set_line("\\x1b[48;5;238m\\x1b[38;5;15m Statusline: starting… \\x1b[0m")
|
|
2110
|
+
renderer.force_repaint(True)
|
|
2111
|
+
|
|
2112
|
+
physical_rows, physical_cols = _term_size()
|
|
2113
|
+
effective_reserved_rows = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
|
|
2114
|
+
renderer.set_active_reserved_rows(effective_reserved_rows)
|
|
2115
|
+
|
|
2116
|
+
child_rows = max(4, physical_rows - effective_reserved_rows)
|
|
2117
|
+
child_cols = max(10, physical_cols)
|
|
2118
|
+
|
|
2119
|
+
# Reserve the bottom rows up-front, before the child starts writing.
|
|
2120
|
+
try:
|
|
2121
|
+
seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[1;1H\\x1b[?25h\\x1b[?2026l"
|
|
2122
|
+
os.write(sys.stdout.fileno(), seq.encode("utf-8", "ignore"))
|
|
2123
|
+
except Exception:
|
|
2124
|
+
pass
|
|
2125
|
+
renderer.force_repaint(True)
|
|
2126
|
+
renderer.render(physical_rows, physical_cols)
|
|
2127
|
+
|
|
2128
|
+
master_fd, slave_fd = pty.openpty()
|
|
2129
|
+
_set_winsize(slave_fd, child_rows, child_cols)
|
|
2130
|
+
|
|
2131
|
+
child = subprocess.Popen(
|
|
2132
|
+
[EXEC_TARGET] + sys.argv[1:],
|
|
2133
|
+
stdin=slave_fd,
|
|
2134
|
+
stdout=slave_fd,
|
|
2135
|
+
stderr=slave_fd,
|
|
2136
|
+
close_fds=True,
|
|
2137
|
+
start_new_session=True,
|
|
2138
|
+
)
|
|
2139
|
+
os.close(slave_fd)
|
|
2140
|
+
|
|
2141
|
+
rewriter = OutputRewriter()
|
|
2142
|
+
cursor = CursorTracker()
|
|
2143
|
+
|
|
2144
|
+
monitor = None
|
|
2145
|
+
try:
|
|
2146
|
+
monitor_env = os.environ.copy()
|
|
2147
|
+
try:
|
|
2148
|
+
monitor_env["DROID_STATUSLINE_PGID"] = str(os.getpgid(child.pid))
|
|
2149
|
+
except Exception:
|
|
2150
|
+
monitor_env["DROID_STATUSLINE_PGID"] = str(child.pid)
|
|
2151
|
+
monitor = subprocess.Popen(
|
|
2152
|
+
["node", STATUSLINE_MONITOR] + sys.argv[1:],
|
|
2153
|
+
stdin=subprocess.DEVNULL,
|
|
2154
|
+
stdout=subprocess.PIPE,
|
|
2155
|
+
stderr=subprocess.DEVNULL,
|
|
2156
|
+
close_fds=True,
|
|
2157
|
+
bufsize=0,
|
|
2158
|
+
env=monitor_env,
|
|
2159
|
+
)
|
|
2160
|
+
except Exception:
|
|
2161
|
+
monitor = None
|
|
2162
|
+
|
|
2163
|
+
monitor_fd = monitor.stdout.fileno() if (monitor and monitor.stdout) else None
|
|
2164
|
+
|
|
2165
|
+
def forward(sig, _frame):
|
|
2166
|
+
try:
|
|
2167
|
+
os.killpg(child.pid, sig)
|
|
2168
|
+
except Exception:
|
|
2169
|
+
pass
|
|
2170
|
+
|
|
2171
|
+
for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
2172
|
+
try:
|
|
2173
|
+
signal.signal(s, forward)
|
|
2174
|
+
except Exception:
|
|
2175
|
+
pass
|
|
2176
|
+
|
|
2177
|
+
stdin_fd = sys.stdin.fileno()
|
|
2178
|
+
stdout_fd = sys.stdout.fileno()
|
|
2179
|
+
old_tty = termios.tcgetattr(stdin_fd)
|
|
2180
|
+
try:
|
|
2181
|
+
tty.setraw(stdin_fd)
|
|
2182
|
+
# Ensure stdout is blocking (prevents sporadic EAGAIN/BlockingIOError on some terminals).
|
|
2183
|
+
try:
|
|
2184
|
+
os.set_blocking(stdout_fd, True)
|
|
2185
|
+
except Exception:
|
|
2186
|
+
pass
|
|
2187
|
+
os.set_blocking(stdin_fd, False)
|
|
2188
|
+
os.set_blocking(master_fd, False)
|
|
2189
|
+
if monitor_fd is not None:
|
|
2190
|
+
os.set_blocking(monitor_fd, False)
|
|
2191
|
+
|
|
2192
|
+
monitor_buf = b""
|
|
2193
|
+
detect_buf = b""
|
|
2194
|
+
cursor_visible = True
|
|
2195
|
+
last_physical_rows = 0
|
|
2196
|
+
last_physical_cols = 0
|
|
2197
|
+
scroll_region_dirty = True
|
|
2198
|
+
last_force_repaint_ms = int(time.time() * 1000)
|
|
2199
|
+
|
|
2200
|
+
while True:
|
|
2201
|
+
if child.poll() is not None:
|
|
2202
|
+
break
|
|
2203
|
+
|
|
2204
|
+
read_fds = [master_fd, stdin_fd]
|
|
2205
|
+
if monitor_fd is not None:
|
|
2206
|
+
read_fds.append(monitor_fd)
|
|
2207
|
+
|
|
2208
|
+
try:
|
|
2209
|
+
rlist, _, _ = select.select(read_fds, [], [], 0.05)
|
|
2210
|
+
except InterruptedError:
|
|
2211
|
+
rlist = []
|
|
2212
|
+
|
|
2213
|
+
pty_eof = False
|
|
2214
|
+
for fd in rlist:
|
|
2215
|
+
if fd == stdin_fd:
|
|
2216
|
+
try:
|
|
2217
|
+
data = os.read(stdin_fd, 4096)
|
|
2218
|
+
if data:
|
|
2219
|
+
os.write(master_fd, data)
|
|
2220
|
+
except BlockingIOError:
|
|
2221
|
+
pass
|
|
2222
|
+
except OSError:
|
|
2223
|
+
pass
|
|
2224
|
+
elif fd == master_fd:
|
|
2225
|
+
try:
|
|
2226
|
+
data = os.read(master_fd, 65536)
|
|
2227
|
+
except BlockingIOError:
|
|
2228
|
+
data = b""
|
|
2229
|
+
except OSError:
|
|
2230
|
+
data = b""
|
|
2231
|
+
|
|
2232
|
+
if data:
|
|
2233
|
+
detect_buf = (detect_buf + data)[-128:]
|
|
2234
|
+
# Detect sequences that may affect scroll region or clear screen
|
|
2235
|
+
needs_scroll_region_reset = (
|
|
2236
|
+
(b"\\x1b[?1049" in detect_buf) # Alt screen
|
|
2237
|
+
or (b"\\x1b[?1047" in detect_buf) # Alt screen
|
|
2238
|
+
or (b"\\x1b[?47" in detect_buf) # Alt screen
|
|
2239
|
+
or (b"\\x1b[J" in detect_buf) # Clear below
|
|
2240
|
+
or (b"\\x1b[0J" in detect_buf) # Clear below
|
|
2241
|
+
or (b"\\x1b[1J" in detect_buf) # Clear above
|
|
2242
|
+
or (b"\\x1b[2J" in detect_buf) # Clear all
|
|
2243
|
+
or (b"\\x1b[3J" in detect_buf) # Clear scrollback
|
|
2244
|
+
or (b"\\x1b[r" in detect_buf) # Reset scroll region (bare ESC[r)
|
|
2245
|
+
)
|
|
2246
|
+
# Also detect scroll region changes with parameters (DECSTBM pattern ESC[n;mr)
|
|
2247
|
+
if b"\\x1b[" in detect_buf and b"r" in detect_buf:
|
|
2248
|
+
if re.search(b"\\x1b\\\\[[0-9]*;?[0-9]*r", detect_buf):
|
|
2249
|
+
needs_scroll_region_reset = True
|
|
2250
|
+
if needs_scroll_region_reset:
|
|
2251
|
+
renderer.force_repaint(True)
|
|
2252
|
+
scroll_region_dirty = True
|
|
2253
|
+
h = detect_buf.rfind(b"\\x1b[?25h")
|
|
2254
|
+
l = detect_buf.rfind(b"\\x1b[?25l")
|
|
2255
|
+
if h != -1 or l != -1:
|
|
2256
|
+
cursor_visible = h > l
|
|
2257
|
+
renderer.set_cursor_visible(cursor_visible)
|
|
2258
|
+
renderer.note_child_output()
|
|
2259
|
+
data = rewriter.feed(data, child_rows)
|
|
2260
|
+
cursor.feed(data, child_rows, child_cols)
|
|
2261
|
+
try:
|
|
2262
|
+
os.write(stdout_fd, data)
|
|
2263
|
+
except BlockingIOError:
|
|
2264
|
+
# If stdout is non-blocking for some reason, retry briefly.
|
|
2265
|
+
try:
|
|
2266
|
+
time.sleep(0.01)
|
|
2267
|
+
os.write(stdout_fd, data)
|
|
2268
|
+
except Exception:
|
|
2269
|
+
pass
|
|
2270
|
+
except OSError:
|
|
2271
|
+
pass
|
|
2272
|
+
else:
|
|
2273
|
+
pty_eof = True
|
|
2274
|
+
elif monitor_fd is not None and fd == monitor_fd:
|
|
2275
|
+
try:
|
|
2276
|
+
chunk = os.read(monitor_fd, 65536)
|
|
2277
|
+
except BlockingIOError:
|
|
2278
|
+
chunk = b""
|
|
2279
|
+
except OSError:
|
|
2280
|
+
chunk = b""
|
|
2281
|
+
|
|
2282
|
+
if chunk:
|
|
2283
|
+
monitor_buf += chunk
|
|
2284
|
+
while True:
|
|
2285
|
+
nl = monitor_buf.find(b"\\n")
|
|
2286
|
+
if nl == -1:
|
|
2287
|
+
break
|
|
2288
|
+
raw = monitor_buf[:nl].rstrip(b"\\r")
|
|
2289
|
+
monitor_buf = monitor_buf[nl + 1 :]
|
|
2290
|
+
if not raw:
|
|
2291
|
+
continue
|
|
2292
|
+
renderer.set_line(raw.decode("utf-8", "replace"))
|
|
2293
|
+
else:
|
|
2294
|
+
monitor_fd = None
|
|
2295
|
+
|
|
2296
|
+
if pty_eof:
|
|
2297
|
+
break
|
|
2298
|
+
|
|
2299
|
+
physical_rows, physical_cols = _term_size()
|
|
2300
|
+
size_changed = (physical_rows != last_physical_rows) or (physical_cols != last_physical_cols)
|
|
2301
|
+
|
|
2302
|
+
desired = renderer.desired_reserved_rows(physical_rows, physical_cols, RESERVED_ROWS)
|
|
2303
|
+
if size_changed or (desired != effective_reserved_rows):
|
|
2304
|
+
cr, cc = cursor.position()
|
|
2305
|
+
if desired < effective_reserved_rows:
|
|
2306
|
+
renderer.clear_reserved_area(physical_rows, physical_cols, effective_reserved_rows, cr, cc)
|
|
2307
|
+
|
|
2308
|
+
effective_reserved_rows = desired
|
|
2309
|
+
renderer.set_active_reserved_rows(effective_reserved_rows)
|
|
2310
|
+
|
|
2311
|
+
child_rows = max(4, physical_rows - effective_reserved_rows)
|
|
2312
|
+
child_cols = max(10, physical_cols)
|
|
2313
|
+
_set_winsize(master_fd, child_rows, child_cols)
|
|
2314
|
+
try:
|
|
2315
|
+
os.killpg(child.pid, signal.SIGWINCH)
|
|
2316
|
+
except Exception:
|
|
2317
|
+
pass
|
|
2318
|
+
|
|
2319
|
+
scroll_region_dirty = True
|
|
2320
|
+
renderer.force_repaint(urgent=True) # Use urgent mode to ensure immediate repaint
|
|
2321
|
+
last_physical_rows = physical_rows
|
|
2322
|
+
last_physical_cols = physical_cols
|
|
2323
|
+
|
|
2324
|
+
cr, cc = cursor.position()
|
|
2325
|
+
if cr < 1:
|
|
2326
|
+
cr = 1
|
|
2327
|
+
if cc < 1:
|
|
2328
|
+
cc = 1
|
|
2329
|
+
if cr > child_rows:
|
|
2330
|
+
cr = child_rows
|
|
2331
|
+
if cc > child_cols:
|
|
2332
|
+
cc = child_cols
|
|
2333
|
+
|
|
2334
|
+
if scroll_region_dirty:
|
|
2335
|
+
# Keep the reserved rows out of the terminal scroll region (esp. after resize).
|
|
2336
|
+
try:
|
|
2337
|
+
seq = f"\\x1b[?2026h\\x1b[?25l\\x1b[1;{child_rows}r\\x1b[{cr};{cc}H"
|
|
2338
|
+
seq += "\\x1b[?25h" if cursor_visible else "\\x1b[?25l"
|
|
2339
|
+
seq += "\\x1b[?2026l"
|
|
2340
|
+
os.write(stdout_fd, seq.encode("utf-8", "ignore"))
|
|
2341
|
+
except Exception:
|
|
2342
|
+
pass
|
|
2343
|
+
scroll_region_dirty = False
|
|
2344
|
+
|
|
2345
|
+
# Periodic force repaint to ensure statusline doesn't disappear
|
|
2346
|
+
now_ms = int(time.time() * 1000)
|
|
2347
|
+
if now_ms - last_force_repaint_ms >= FORCE_REPAINT_INTERVAL_MS:
|
|
2348
|
+
renderer.force_repaint(False)
|
|
2349
|
+
last_force_repaint_ms = now_ms
|
|
2350
|
+
|
|
2351
|
+
renderer.render(physical_rows, physical_cols, cr, cc)
|
|
2352
|
+
|
|
2353
|
+
finally:
|
|
2354
|
+
try:
|
|
2355
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty)
|
|
2356
|
+
except Exception:
|
|
2357
|
+
pass
|
|
2358
|
+
try:
|
|
2359
|
+
renderer.clear()
|
|
2360
|
+
except Exception:
|
|
2361
|
+
pass
|
|
2362
|
+
try:
|
|
2363
|
+
# Restore terminal scroll region and attributes.
|
|
2364
|
+
os.write(stdout_fd, b"\\x1b[r\\x1b[0m\\x1b[?25h")
|
|
2365
|
+
except Exception:
|
|
2366
|
+
pass
|
|
2367
|
+
try:
|
|
2368
|
+
os.close(master_fd)
|
|
2369
|
+
except Exception:
|
|
2370
|
+
pass
|
|
2371
|
+
if monitor is not None:
|
|
2372
|
+
try:
|
|
2373
|
+
monitor.terminate()
|
|
2374
|
+
except Exception:
|
|
2375
|
+
pass
|
|
2376
|
+
|
|
2377
|
+
sys.exit(child.returncode or 0)
|
|
2378
|
+
|
|
2379
|
+
|
|
2380
|
+
if __name__ == "__main__":
|
|
2381
|
+
main()
|
|
2382
|
+
`;
|
|
2383
|
+
}
|
|
2384
|
+
async function createStatuslineFiles(outputDir, execTargetPath, aliasName) {
|
|
2385
|
+
if (!existsSync(outputDir)) await mkdir(outputDir, { recursive: true });
|
|
2386
|
+
const monitorScriptPath = join(outputDir, `${aliasName}-statusline.js`);
|
|
2387
|
+
const wrapperScriptPath = join(outputDir, aliasName);
|
|
2388
|
+
await writeFile(monitorScriptPath, generateStatuslineMonitorScript());
|
|
2389
|
+
await chmod(monitorScriptPath, 493);
|
|
2390
|
+
await writeFile(wrapperScriptPath, generateStatuslineWrapperScript(execTargetPath, monitorScriptPath));
|
|
2391
|
+
await chmod(wrapperScriptPath, 493);
|
|
2392
|
+
return {
|
|
2393
|
+
wrapperScript: wrapperScriptPath,
|
|
2394
|
+
monitorScript: monitorScriptPath
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2397
|
+
|
|
626
2398
|
//#endregion
|
|
627
2399
|
//#region src/cli.ts
|
|
628
2400
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -675,12 +2447,13 @@ function findDefaultDroidPath() {
|
|
|
675
2447
|
for (const p of paths) if (existsSync(p)) return p;
|
|
676
2448
|
return join(home, ".droid", "bin", "droid");
|
|
677
2449
|
}
|
|
678
|
-
bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
|
|
2450
|
+
bin("droid-patch", "CLI tool to patch droid binary with various modifications").package("droid-patch", version).option("--is-custom", "Patch isCustom:!0 to isCustom:!1 (enable context compression for custom models)").option("--skip-login", "Inject a fake FACTORY_API_KEY to bypass login requirement (no real key needed)").option("--api-base <url>", "Replace API URL (standalone: binary patch, max 22 chars; with --websearch: proxy forward target, no limit)").option("--websearch", "Enable local WebSearch proxy (each instance runs own proxy, auto-cleanup on exit)").option("--statusline", "Enable a Claude-style statusline (terminal UI)").option("--standalone", "Standalone mode: mock non-LLM Factory APIs (use with --websearch)").option("--reasoning-effort", "Enable reasoning effort for custom models (set to high, enable UI selector)").option("--disable-telemetry", "Disable telemetry and Sentry error reporting (block data uploads)").option("--dry-run", "Verify patches without actually modifying the binary").option("-p, --path <path>", "Path to the droid binary").option("-o, --output <dir>", "Output directory for patched binary").option("--no-backup", "Do not create backup of original binary").option("-v, --verbose", "Enable verbose output").argument("[alias]", "Alias name for the patched binary").action(async (options, args) => {
|
|
679
2451
|
const alias = args?.[0];
|
|
680
2452
|
const isCustom = options["is-custom"];
|
|
681
2453
|
const skipLogin = options["skip-login"];
|
|
682
2454
|
const apiBase = options["api-base"];
|
|
683
2455
|
const websearch = options["websearch"];
|
|
2456
|
+
const statusline = options["statusline"];
|
|
684
2457
|
const standalone = options["standalone"];
|
|
685
2458
|
const websearchTarget = websearch ? apiBase || "https://api.factory.ai" : void 0;
|
|
686
2459
|
const reasoningEffort = options["reasoning-effort"];
|
|
@@ -691,65 +2464,83 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
691
2464
|
const backup = options.backup !== false;
|
|
692
2465
|
const verbose = options.verbose;
|
|
693
2466
|
const outputPath = outputDir && alias ? join(outputDir, alias) : void 0;
|
|
694
|
-
if (
|
|
2467
|
+
if (!(!!isCustom || !!skipLogin || !!reasoningEffort || !!noTelemetry || !!apiBase && !websearch) && (websearch || statusline)) {
|
|
695
2468
|
if (!alias) {
|
|
696
|
-
console.log(styleText("red", "Error: Alias name required for --websearch"));
|
|
2469
|
+
console.log(styleText("red", "Error: Alias name required for --websearch/--statusline"));
|
|
697
2470
|
console.log(styleText("gray", "Usage: npx droid-patch --websearch <alias>"));
|
|
2471
|
+
console.log(styleText("gray", "Usage: npx droid-patch --statusline <alias>"));
|
|
698
2472
|
process.exit(1);
|
|
699
2473
|
}
|
|
700
2474
|
console.log(styleText("cyan", "═".repeat(60)));
|
|
701
|
-
console.log(styleText(["cyan", "bold"], " Droid
|
|
2475
|
+
console.log(styleText(["cyan", "bold"], " Droid Wrapper Setup"));
|
|
702
2476
|
console.log(styleText("cyan", "═".repeat(60)));
|
|
703
2477
|
console.log();
|
|
704
|
-
|
|
705
|
-
|
|
2478
|
+
if (websearch) {
|
|
2479
|
+
console.log(styleText("white", `WebSearch: enabled`));
|
|
2480
|
+
console.log(styleText("white", `Forward target: ${websearchTarget}`));
|
|
2481
|
+
if (standalone) console.log(styleText("white", `Standalone mode: enabled`));
|
|
2482
|
+
}
|
|
2483
|
+
if (statusline) console.log(styleText("white", `Statusline: enabled`));
|
|
706
2484
|
console.log();
|
|
707
|
-
|
|
708
|
-
|
|
2485
|
+
let execTargetPath = path;
|
|
2486
|
+
if (websearch) {
|
|
2487
|
+
const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
|
|
2488
|
+
execTargetPath = wrapperScript;
|
|
2489
|
+
}
|
|
2490
|
+
if (statusline) {
|
|
2491
|
+
const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
|
|
2492
|
+
execTargetPath = wrapperScript;
|
|
2493
|
+
}
|
|
2494
|
+
const aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
|
|
709
2495
|
const droidVersion = getDroidVersion(path);
|
|
710
2496
|
await saveAliasMetadata(createMetadata(alias, path, {
|
|
711
2497
|
isCustom: false,
|
|
712
2498
|
skipLogin: false,
|
|
713
2499
|
apiBase: apiBase || null,
|
|
714
|
-
websearch:
|
|
2500
|
+
websearch: !!websearch,
|
|
2501
|
+
statusline: !!statusline,
|
|
715
2502
|
reasoningEffort: false,
|
|
716
2503
|
noTelemetry: false,
|
|
717
2504
|
standalone
|
|
718
2505
|
}, {
|
|
719
2506
|
droidPatchVersion: version,
|
|
720
|
-
droidVersion
|
|
2507
|
+
droidVersion,
|
|
2508
|
+
aliasPath: aliasResult.aliasPath
|
|
721
2509
|
}));
|
|
722
2510
|
console.log();
|
|
723
2511
|
console.log(styleText("green", "═".repeat(60)));
|
|
724
|
-
console.log(styleText(["green", "bold"], "
|
|
2512
|
+
console.log(styleText(["green", "bold"], " Wrapper Ready!"));
|
|
725
2513
|
console.log(styleText("green", "═".repeat(60)));
|
|
726
2514
|
console.log();
|
|
727
2515
|
console.log("Run directly:");
|
|
728
2516
|
console.log(styleText("yellow", ` ${alias}`));
|
|
729
2517
|
console.log();
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
2518
|
+
if (websearch) {
|
|
2519
|
+
console.log(styleText("cyan", "Auto-shutdown:"));
|
|
2520
|
+
console.log(styleText("gray", " Proxy auto-shuts down after 5 min idle (no manual cleanup needed)"));
|
|
2521
|
+
console.log(styleText("gray", " To disable: export DROID_PROXY_IDLE_TIMEOUT=0"));
|
|
2522
|
+
console.log();
|
|
2523
|
+
console.log("Search providers (in priority order):");
|
|
2524
|
+
console.log(styleText("yellow", " 1. Smithery Exa (best quality):"));
|
|
2525
|
+
console.log(styleText("gray", " export SMITHERY_API_KEY=your_api_key"));
|
|
2526
|
+
console.log(styleText("gray", " export SMITHERY_PROFILE=your_profile"));
|
|
2527
|
+
console.log(styleText("gray", " 2. Google PSE:"));
|
|
2528
|
+
console.log(styleText("gray", " export GOOGLE_PSE_API_KEY=your_api_key"));
|
|
2529
|
+
console.log(styleText("gray", " export GOOGLE_PSE_CX=your_search_engine_id"));
|
|
2530
|
+
console.log(styleText("gray", " 3-6. Serper, Brave, SearXNG, DuckDuckGo (fallbacks)"));
|
|
2531
|
+
console.log();
|
|
2532
|
+
console.log("Debug mode:");
|
|
2533
|
+
console.log(styleText("gray", " export DROID_SEARCH_DEBUG=1"));
|
|
2534
|
+
}
|
|
745
2535
|
return;
|
|
746
2536
|
}
|
|
747
|
-
if (!isCustom && !skipLogin && !apiBase && !websearch && !reasoningEffort && !noTelemetry) {
|
|
2537
|
+
if (!isCustom && !skipLogin && !apiBase && !websearch && !statusline && !reasoningEffort && !noTelemetry) {
|
|
748
2538
|
console.log(styleText("yellow", "No patch flags specified. Available patches:"));
|
|
749
2539
|
console.log(styleText("gray", " --is-custom Patch isCustom for custom models"));
|
|
750
2540
|
console.log(styleText("gray", " --skip-login Bypass login by injecting a fake API key"));
|
|
751
2541
|
console.log(styleText("gray", " --api-base Replace API URL (standalone: max 22 chars; with --websearch: no limit)"));
|
|
752
2542
|
console.log(styleText("gray", " --websearch Enable local WebSearch proxy"));
|
|
2543
|
+
console.log(styleText("gray", " --statusline Enable Claude-style statusline"));
|
|
753
2544
|
console.log(styleText("gray", " --reasoning-effort Set reasoning effort level for custom models"));
|
|
754
2545
|
console.log(styleText("gray", " --disable-telemetry Disable telemetry and Sentry error reporting"));
|
|
755
2546
|
console.log(styleText("gray", " --standalone Standalone mode: mock non-LLM Factory APIs"));
|
|
@@ -760,6 +2551,8 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
760
2551
|
console.log(styleText("cyan", " npx droid-patch --is-custom --skip-login droid-patched"));
|
|
761
2552
|
console.log(styleText("cyan", " npx droid-patch --websearch droid-search"));
|
|
762
2553
|
console.log(styleText("cyan", " npx droid-patch --websearch --standalone droid-local"));
|
|
2554
|
+
console.log(styleText("cyan", " npx droid-patch --statusline droid-status"));
|
|
2555
|
+
console.log(styleText("cyan", " npx droid-patch --websearch --statusline droid-search-ui"));
|
|
763
2556
|
console.log(styleText("cyan", " npx droid-patch --disable-telemetry droid-private"));
|
|
764
2557
|
console.log(styleText("cyan", " npx droid-patch --websearch --api-base=http://127.0.0.1:20002 my-droid"));
|
|
765
2558
|
process.exit(1);
|
|
@@ -891,26 +2684,38 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
891
2684
|
}
|
|
892
2685
|
if (result.success && result.outputPath && alias) {
|
|
893
2686
|
console.log();
|
|
2687
|
+
let execTargetPath = result.outputPath;
|
|
894
2688
|
if (websearch) {
|
|
895
|
-
const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"),
|
|
896
|
-
|
|
2689
|
+
const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, alias, websearchTarget, standalone);
|
|
2690
|
+
execTargetPath = wrapperScript;
|
|
897
2691
|
console.log();
|
|
898
2692
|
console.log(styleText("cyan", "WebSearch enabled"));
|
|
899
2693
|
console.log(styleText("white", ` Forward target: ${websearchTarget}`));
|
|
900
2694
|
if (standalone) console.log(styleText("white", ` Standalone mode: enabled`));
|
|
901
|
-
}
|
|
2695
|
+
}
|
|
2696
|
+
if (statusline) {
|
|
2697
|
+
const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, alias);
|
|
2698
|
+
execTargetPath = wrapperScript;
|
|
2699
|
+
console.log();
|
|
2700
|
+
console.log(styleText("cyan", "Statusline enabled"));
|
|
2701
|
+
}
|
|
2702
|
+
let aliasResult;
|
|
2703
|
+
if (websearch || statusline) aliasResult = await createAliasForWrapper(execTargetPath, alias, verbose);
|
|
2704
|
+
else aliasResult = await createAlias(result.outputPath, alias, verbose);
|
|
902
2705
|
const droidVersion = getDroidVersion(path);
|
|
903
2706
|
await saveAliasMetadata(createMetadata(alias, path, {
|
|
904
2707
|
isCustom: !!isCustom,
|
|
905
2708
|
skipLogin: !!skipLogin,
|
|
906
2709
|
apiBase: apiBase || null,
|
|
907
2710
|
websearch: !!websearch,
|
|
2711
|
+
statusline: !!statusline,
|
|
908
2712
|
reasoningEffort: !!reasoningEffort,
|
|
909
2713
|
noTelemetry: !!noTelemetry,
|
|
910
2714
|
standalone: !!standalone
|
|
911
2715
|
}, {
|
|
912
2716
|
droidPatchVersion: version,
|
|
913
|
-
droidVersion
|
|
2717
|
+
droidVersion,
|
|
2718
|
+
aliasPath: aliasResult.aliasPath
|
|
914
2719
|
}));
|
|
915
2720
|
}
|
|
916
2721
|
if (result.success) {
|
|
@@ -927,7 +2732,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
927
2732
|
}
|
|
928
2733
|
}).command("list", "List all droid-patch aliases").action(async () => {
|
|
929
2734
|
await listAliases();
|
|
930
|
-
}).command("remove", "Remove alias(es) by name or filter").argument("[alias-or-path]", "Alias name or file path to remove").option("--patch-version <version>", "Remove aliases created by this droid-patch version").option("--droid-version <version>", "Remove aliases for this droid version").option("--flag <flag>", "Remove aliases with this flag (is-custom, skip-login, websearch, api-base, reasoning-effort, disable-telemetry, standalone)").action(async (options, args) => {
|
|
2735
|
+
}).command("remove", "Remove alias(es) by name or filter").argument("[alias-or-path]", "Alias name or file path to remove").option("--patch-version <version>", "Remove aliases created by this droid-patch version").option("--droid-version <version>", "Remove aliases for this droid version").option("--flag <flag>", "Remove aliases with this flag (is-custom, skip-login, websearch, statusline, api-base, reasoning-effort, disable-telemetry, standalone)").action(async (options, args) => {
|
|
931
2736
|
const target = args?.[0];
|
|
932
2737
|
const patchVersion = options["patch-version"];
|
|
933
2738
|
const droidVersion = options["droid-version"];
|
|
@@ -1020,7 +2825,7 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
1020
2825
|
pattern: Buffer.from("process.env.FACTORY_API_KEY"),
|
|
1021
2826
|
replacement: Buffer.from("\"fk-droid-patch-skip-00000\"")
|
|
1022
2827
|
});
|
|
1023
|
-
if (meta.patches.apiBase) {
|
|
2828
|
+
if (meta.patches.apiBase && !meta.patches.websearch) {
|
|
1024
2829
|
const originalUrl = "https://api.factory.ai";
|
|
1025
2830
|
const paddedUrl = meta.patches.apiBase.padEnd(22, " ");
|
|
1026
2831
|
patches.push({
|
|
@@ -1104,9 +2909,11 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
1104
2909
|
console.log(styleText("yellow", ` [!] Could not re-sign binary`));
|
|
1105
2910
|
}
|
|
1106
2911
|
}
|
|
2912
|
+
let execTargetPath = patches.length > 0 ? outputPath : newBinaryPath;
|
|
1107
2913
|
if (meta.patches.websearch || !!meta.patches.proxy) {
|
|
1108
2914
|
const forwardTarget = meta.patches.apiBase || meta.patches.proxy || "https://api.factory.ai";
|
|
1109
|
-
await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"),
|
|
2915
|
+
const { wrapperScript } = await createWebSearchUnifiedFiles(join(homedir(), ".droid-patch", "proxy"), execTargetPath, meta.name, forwardTarget, meta.patches.standalone || false);
|
|
2916
|
+
execTargetPath = wrapperScript;
|
|
1110
2917
|
if (verbose) {
|
|
1111
2918
|
console.log(styleText("gray", ` Regenerated websearch wrapper`));
|
|
1112
2919
|
if (meta.patches.standalone) console.log(styleText("gray", ` Standalone mode: enabled`));
|
|
@@ -1117,8 +2924,55 @@ bin("droid-patch", "CLI tool to patch droid binary with various modifications").
|
|
|
1117
2924
|
delete meta.patches.proxy;
|
|
1118
2925
|
}
|
|
1119
2926
|
}
|
|
2927
|
+
if (meta.patches.statusline) {
|
|
2928
|
+
const { wrapperScript } = await createStatuslineFiles(join(homedir(), ".droid-patch", "statusline"), execTargetPath, meta.name);
|
|
2929
|
+
execTargetPath = wrapperScript;
|
|
2930
|
+
if (verbose) console.log(styleText("gray", ` Regenerated statusline wrapper`));
|
|
2931
|
+
}
|
|
2932
|
+
const { symlink: symlink$1, unlink: unlink$1, readlink: readlink$1, lstat } = await import("node:fs/promises");
|
|
2933
|
+
let aliasPath = meta.aliasPath;
|
|
2934
|
+
if (!aliasPath) {
|
|
2935
|
+
const commonPathDirs = [
|
|
2936
|
+
join(homedir(), ".local/bin"),
|
|
2937
|
+
join(homedir(), "bin"),
|
|
2938
|
+
join(homedir(), ".bin"),
|
|
2939
|
+
"/opt/homebrew/bin",
|
|
2940
|
+
"/usr/local/bin",
|
|
2941
|
+
join(homedir(), ".droid-patch", "aliases")
|
|
2942
|
+
];
|
|
2943
|
+
for (const dir of commonPathDirs) {
|
|
2944
|
+
const possiblePath = join(dir, meta.name);
|
|
2945
|
+
if (existsSync(possiblePath)) try {
|
|
2946
|
+
if ((await lstat(possiblePath)).isSymbolicLink()) {
|
|
2947
|
+
const target = await readlink$1(possiblePath);
|
|
2948
|
+
if (target.includes(".droid-patch/bins") || target.includes(".droid-patch/proxy") || target.includes(".droid-patch/statusline")) {
|
|
2949
|
+
aliasPath = possiblePath;
|
|
2950
|
+
if (verbose) console.log(styleText("gray", ` Found existing symlink: ${aliasPath}`));
|
|
2951
|
+
break;
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
} catch {}
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (aliasPath) try {
|
|
2958
|
+
if (existsSync(aliasPath)) {
|
|
2959
|
+
if (await readlink$1(aliasPath) !== execTargetPath) {
|
|
2960
|
+
await unlink$1(aliasPath);
|
|
2961
|
+
await symlink$1(execTargetPath, aliasPath);
|
|
2962
|
+
if (verbose) console.log(styleText("gray", ` Updated symlink: ${aliasPath}`));
|
|
2963
|
+
}
|
|
2964
|
+
} else {
|
|
2965
|
+
await symlink$1(execTargetPath, aliasPath);
|
|
2966
|
+
if (verbose) console.log(styleText("gray", ` Recreated symlink: ${aliasPath}`));
|
|
2967
|
+
}
|
|
2968
|
+
meta.aliasPath = aliasPath;
|
|
2969
|
+
} catch (symlinkError) {
|
|
2970
|
+
console.log(styleText("yellow", ` [!] Could not update symlink: ${symlinkError.message}`));
|
|
2971
|
+
}
|
|
1120
2972
|
meta.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1121
2973
|
meta.originalBinaryPath = newBinaryPath;
|
|
2974
|
+
meta.droidVersion = getDroidVersion(newBinaryPath);
|
|
2975
|
+
meta.droidPatchVersion = version;
|
|
1122
2976
|
await saveAliasMetadata(meta);
|
|
1123
2977
|
console.log(styleText("green", ` ✓ Updated successfully`));
|
|
1124
2978
|
successCount++;
|