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