codemaxxing 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.tsx CHANGED
@@ -6,13 +6,13 @@ import { EventEmitter } from "events";
6
6
  import TextInput from "ink-text-input";
7
7
  import { CodingAgent } from "./agent.js";
8
8
  import { loadConfig, detectLocalProvider, parseCLIArgs, applyOverrides, listModels } from "./config.js";
9
- import { listSessions, getSession, loadMessages } from "./utils/sessions.js";
9
+ import { listSessions, getSession, loadMessages, deleteSession } from "./utils/sessions.js";
10
10
  import { execSync } from "child_process";
11
11
  import { isGitRepo, getBranch, getStatus, getDiff, undoLastCommit } from "./utils/git.js";
12
12
  import { getTheme, listThemes, THEMES, DEFAULT_THEME, type Theme } from "./themes.js";
13
13
  import { PROVIDERS, getCredentials, openRouterOAuth, anthropicSetupToken, importCodexToken, importQwenToken, copilotDeviceFlow, saveApiKey } from "./utils/auth.js";
14
14
 
15
- const VERSION = "0.1.5";
15
+ const VERSION = "0.1.9";
16
16
 
17
17
  // ── Helpers ──
18
18
  function formatTimeAgo(date: Date): string {
@@ -43,6 +43,7 @@ const SLASH_COMMANDS = [
43
43
  { cmd: "/theme", desc: "switch color theme" },
44
44
  { cmd: "/model", desc: "switch model mid-session" },
45
45
  { cmd: "/sessions", desc: "list past sessions" },
46
+ { cmd: "/session delete", desc: "delete a session" },
46
47
  { cmd: "/resume", desc: "resume a past session" },
47
48
  { cmd: "/quit", desc: "exit" },
48
49
  ];
@@ -135,11 +136,17 @@ function App() {
135
136
  const [sessionPickerIndex, setSessionPickerIndex] = useState(0);
136
137
  const [themePicker, setThemePicker] = useState(false);
137
138
  const [themePickerIndex, setThemePickerIndex] = useState(0);
139
+ const [deleteSessionPicker, setDeleteSessionPicker] = useState<Array<{ id: string; display: string }> | null>(null);
140
+ const [deleteSessionPickerIndex, setDeleteSessionPickerIndex] = useState(0);
141
+ const [deleteSessionConfirm, setDeleteSessionConfirm] = useState<{ id: string; display: string } | null>(null);
138
142
  const [loginPicker, setLoginPicker] = useState(false);
139
143
  const [loginPickerIndex, setLoginPickerIndex] = useState(0);
144
+ const [loginMethodPicker, setLoginMethodPicker] = useState<{ provider: string; methods: string[] } | null>(null);
145
+ const [loginMethodIndex, setLoginMethodIndex] = useState(0);
140
146
  const [approval, setApproval] = useState<{
141
147
  tool: string;
142
148
  args: Record<string, unknown>;
149
+ diff?: string;
143
150
  resolve: (decision: "yes" | "no" | "always") => void;
144
151
  } | null>(null);
145
152
 
@@ -247,9 +254,15 @@ function App() {
247
254
  onGitCommit: (message) => {
248
255
  addMsg("info", `📝 Auto-committed: ${message}`);
249
256
  },
250
- onToolApproval: (name, args) => {
257
+ onContextCompressed: (oldTokens, newTokens) => {
258
+ const saved = oldTokens - newTokens;
259
+ const savedStr = saved >= 1000 ? `${(saved / 1000).toFixed(1)}k` : String(saved);
260
+ addMsg("info", `📦 Context compressed (~${savedStr} tokens freed)`);
261
+ },
262
+ contextCompressionThreshold: config.defaults.contextCompressionThreshold,
263
+ onToolApproval: (name, args, diff) => {
251
264
  return new Promise((resolve) => {
252
- setApproval({ tool: name, args, resolve });
265
+ setApproval({ tool: name, args, diff, resolve });
253
266
  setLoading(false);
254
267
  });
255
268
  },
@@ -296,7 +309,7 @@ function App() {
296
309
  const selected = matches[idx];
297
310
  if (selected) {
298
311
  // Commands that need args (like /commit, /model) — fill input instead of executing
299
- if (selected.cmd === "/commit" || selected.cmd === "/model") {
312
+ if (selected.cmd === "/commit" || selected.cmd === "/model" || selected.cmd === "/session delete") {
300
313
  setInput(selected.cmd + " ");
301
314
  setCmdIndex(0);
302
315
  setInputKey((k) => k + 1);
@@ -341,6 +354,7 @@ function App() {
341
354
  " /models — list available models",
342
355
  " /map — show repository map",
343
356
  " /sessions — list past sessions",
357
+ " /session delete — delete a session",
344
358
  " /resume — resume a past session",
345
359
  " /reset — clear conversation",
346
360
  " /context — show message count",
@@ -451,12 +465,50 @@ function App() {
451
465
  const tokens = s.token_estimate >= 1000
452
466
  ? `${(s.token_estimate / 1000).toFixed(1)}k`
453
467
  : String(s.token_estimate);
454
- return ` ${s.id} ${dir}/ ${s.message_count} msgs ~${tokens} tok ${ago} ${s.model}`;
468
+ const cost = s.estimated_cost > 0
469
+ ? `$${s.estimated_cost < 0.01 ? s.estimated_cost.toFixed(4) : s.estimated_cost.toFixed(2)}`
470
+ : "";
471
+ return ` ${s.id} ${dir}/ ${s.message_count} msgs ~${tokens} tok${cost ? ` ${cost}` : ""} ${ago} ${s.model}`;
455
472
  });
456
473
  addMsg("info", "Recent sessions:\n" + lines.join("\n") + "\n\n Use /resume <id> to continue a session");
457
474
  }
458
475
  return;
459
476
  }
477
+ if (trimmed.startsWith("/session delete")) {
478
+ const idArg = trimmed.replace("/session delete", "").trim();
479
+ if (idArg) {
480
+ // Direct delete by ID
481
+ const session = getSession(idArg);
482
+ if (!session) {
483
+ addMsg("error", `Session "${idArg}" not found.`);
484
+ return;
485
+ }
486
+ const dir = session.cwd.split("/").pop() || session.cwd;
487
+ setDeleteSessionConfirm({ id: idArg, display: `${idArg} ${dir}/ ${session.message_count} msgs ${session.model}` });
488
+ return;
489
+ }
490
+ // Show picker
491
+ const sessions = listSessions(10);
492
+ if (sessions.length === 0) {
493
+ addMsg("info", "No sessions to delete.");
494
+ return;
495
+ }
496
+ const items = sessions.map((s) => {
497
+ const date = new Date(s.updated_at + "Z");
498
+ const ago = formatTimeAgo(date);
499
+ const dir = s.cwd.split("/").pop() || s.cwd;
500
+ const tokens = s.token_estimate >= 1000
501
+ ? `${(s.token_estimate / 1000).toFixed(1)}k`
502
+ : String(s.token_estimate);
503
+ return {
504
+ id: s.id,
505
+ display: `${s.id} ${dir}/ ${s.message_count} msgs ~${tokens} tok ${ago} ${s.model}`,
506
+ };
507
+ });
508
+ setDeleteSessionPicker(items);
509
+ setDeleteSessionPickerIndex(0);
510
+ return;
511
+ }
460
512
  if (trimmed === "/resume") {
461
513
  const sessions = listSessions(10);
462
514
  if (sessions.length === 0) {
@@ -543,77 +595,115 @@ function App() {
543
595
  }
544
596
  }
545
597
 
546
- // Login picker navigation
547
- if (loginPicker) {
548
- const loginProviders = PROVIDERS.filter((p) => p.id !== "local");
598
+ // Login method picker navigation (second level — pick auth method)
599
+ if (loginMethodPicker) {
600
+ const methods = loginMethodPicker.methods;
549
601
  if (key.upArrow) {
550
- setLoginPickerIndex((prev: number) => (prev - 1 + loginProviders.length) % loginProviders.length);
602
+ setLoginMethodIndex((prev: number) => (prev - 1 + methods.length) % methods.length);
551
603
  return;
552
604
  }
553
605
  if (key.downArrow) {
554
- setLoginPickerIndex((prev: number) => (prev + 1) % loginProviders.length);
606
+ setLoginMethodIndex((prev: number) => (prev + 1) % methods.length);
607
+ return;
608
+ }
609
+ if (key.escape) {
610
+ setLoginMethodPicker(null);
611
+ setLoginPicker(true); // go back to provider picker
555
612
  return;
556
613
  }
557
614
  if (key.return) {
558
- const selected = loginProviders[loginPickerIndex];
559
- setLoginPicker(false);
615
+ const method = methods[loginMethodIndex];
616
+ const providerId = loginMethodPicker.provider;
617
+ setLoginMethodPicker(null);
560
618
 
561
- if (selected.id === "openrouter") {
619
+ if (method === "oauth" && providerId === "openrouter") {
562
620
  addMsg("info", "Starting OpenRouter OAuth — opening browser...");
563
621
  setLoading(true);
564
622
  setSpinnerMsg("Waiting for authorization...");
565
623
  openRouterOAuth((msg: string) => addMsg("info", msg))
566
- .then((cred) => {
567
- addMsg("info", `✅ OpenRouter authenticated! You now have access to 200+ models.`);
568
- addMsg("info", `Switch with: /model anthropic/claude-sonnet-4`);
624
+ .then(() => {
625
+ addMsg("info", `✅ OpenRouter authenticated! Access to 200+ models.`);
569
626
  setLoading(false);
570
627
  })
571
- .catch((err: any) => {
572
- addMsg("error", `OAuth failed: ${err.message}`);
573
- setLoading(false);
574
- });
575
- } else if (selected.id === "anthropic") {
576
- addMsg("info", "Starting Anthropic setup-token flow...");
628
+ .catch((err: any) => { addMsg("error", `OAuth failed: ${err.message}`); setLoading(false); });
629
+ } else if (method === "setup-token") {
630
+ addMsg("info", "Starting setup-token flow — browser will open...");
577
631
  setLoading(true);
578
632
  setSpinnerMsg("Waiting for Claude Code auth...");
579
633
  anthropicSetupToken((msg: string) => addMsg("info", msg))
580
- .then((cred) => {
581
- addMsg("info", `✅ Anthropic authenticated! (${cred.label})`);
582
- setLoading(false);
583
- })
584
- .catch((err: any) => {
585
- addMsg("error", `Anthropic auth failed: ${err.message}`);
586
- setLoading(false);
587
- });
588
- } else if (selected.id === "openai") {
634
+ .then((cred) => { addMsg("info", `✅ Anthropic authenticated! (${cred.label})`); setLoading(false); })
635
+ .catch((err: any) => { addMsg("error", `Auth failed: ${err.message}`); setLoading(false); });
636
+ } else if (method === "cached-token" && providerId === "openai") {
589
637
  const imported = importCodexToken((msg: string) => addMsg("info", msg));
590
- if (imported) {
591
- addMsg("info", `✅ Imported Codex credentials! (${imported.label})`);
592
- } else {
593
- addMsg("info", "No Codex CLI found. Run: codemaxxing auth api-key openai <your-key>");
594
- }
595
- } else if (selected.id === "qwen") {
638
+ if (imported) { addMsg("info", `✅ Imported Codex credentials! (${imported.label})`); }
639
+ else { addMsg("info", "No Codex CLI found. Install Codex CLI and sign in first."); }
640
+ } else if (method === "cached-token" && providerId === "qwen") {
596
641
  const imported = importQwenToken((msg: string) => addMsg("info", msg));
597
- if (imported) {
598
- addMsg("info", `✅ Imported Qwen credentials! (${imported.label})`);
599
- } else {
600
- addMsg("info", "No Qwen CLI found. Run: codemaxxing auth api-key qwen <your-key>");
601
- }
602
- } else if (selected.id === "copilot") {
642
+ if (imported) { addMsg("info", `✅ Imported Qwen credentials! (${imported.label})`); }
643
+ else { addMsg("info", "No Qwen CLI found. Install Qwen CLI and sign in first."); }
644
+ } else if (method === "device-flow") {
603
645
  addMsg("info", "Starting GitHub Copilot device flow...");
604
646
  setLoading(true);
605
647
  setSpinnerMsg("Waiting for GitHub authorization...");
606
648
  copilotDeviceFlow((msg: string) => addMsg("info", msg))
607
- .then(() => {
608
- addMsg("info", `✅ GitHub Copilot authenticated!`);
609
- setLoading(false);
610
- })
611
- .catch((err: any) => {
612
- addMsg("error", `Copilot auth failed: ${err.message}`);
613
- setLoading(false);
614
- });
649
+ .then(() => { addMsg("info", `✅ GitHub Copilot authenticated!`); setLoading(false); })
650
+ .catch((err: any) => { addMsg("error", `Copilot auth failed: ${err.message}`); setLoading(false); });
651
+ } else if (method === "api-key") {
652
+ const provider = PROVIDERS.find((p) => p.id === providerId);
653
+ addMsg("info", `Enter your API key via CLI:\n codemaxxing auth api-key ${providerId} <your-key>\n Get key at: ${provider?.consoleUrl ?? "your provider's dashboard"}`);
654
+ }
655
+ return;
656
+ }
657
+ return;
658
+ }
659
+
660
+ // Login picker navigation (first level — pick provider)
661
+ if (loginPicker) {
662
+ const loginProviders = PROVIDERS.filter((p) => p.id !== "local");
663
+ if (key.upArrow) {
664
+ setLoginPickerIndex((prev: number) => (prev - 1 + loginProviders.length) % loginProviders.length);
665
+ return;
666
+ }
667
+ if (key.downArrow) {
668
+ setLoginPickerIndex((prev: number) => (prev + 1) % loginProviders.length);
669
+ return;
670
+ }
671
+ if (key.return) {
672
+ const selected = loginProviders[loginPickerIndex];
673
+ setLoginPicker(false);
674
+
675
+ // Get available methods for this provider (filter out 'none')
676
+ const methods = selected.methods.filter((m) => m !== "none");
677
+
678
+ if (methods.length === 1) {
679
+ // Only one method — execute it directly
680
+ setLoginMethodPicker({ provider: selected.id, methods });
681
+ setLoginMethodIndex(0);
682
+ // Simulate Enter press on the single method
683
+ if (methods[0] === "oauth" && selected.id === "openrouter") {
684
+ setLoginMethodPicker(null);
685
+ addMsg("info", "Starting OpenRouter OAuth — opening browser...");
686
+ setLoading(true);
687
+ setSpinnerMsg("Waiting for authorization...");
688
+ openRouterOAuth((msg: string) => addMsg("info", msg))
689
+ .then(() => { addMsg("info", `✅ OpenRouter authenticated! Access to 200+ models.`); setLoading(false); })
690
+ .catch((err: any) => { addMsg("error", `OAuth failed: ${err.message}`); setLoading(false); });
691
+ } else if (methods[0] === "device-flow") {
692
+ setLoginMethodPicker(null);
693
+ addMsg("info", "Starting GitHub Copilot device flow...");
694
+ setLoading(true);
695
+ setSpinnerMsg("Waiting for GitHub authorization...");
696
+ copilotDeviceFlow((msg: string) => addMsg("info", msg))
697
+ .then(() => { addMsg("info", `✅ GitHub Copilot authenticated!`); setLoading(false); })
698
+ .catch((err: any) => { addMsg("error", `Copilot auth failed: ${err.message}`); setLoading(false); });
699
+ } else if (methods[0] === "api-key") {
700
+ setLoginMethodPicker(null);
701
+ addMsg("info", `Enter your API key via CLI:\n codemaxxing auth api-key ${selected.id} <your-key>\n Get key at: ${selected.consoleUrl ?? "your provider's dashboard"}`);
702
+ }
615
703
  } else {
616
- addMsg("info", `Run: codemaxxing auth api-key ${selected.id} <your-key>\n Get key at: ${selected.consoleUrl ?? selected.baseUrl}`);
704
+ // Multiple methods show submenu
705
+ setLoginMethodPicker({ provider: selected.id, methods });
706
+ setLoginMethodIndex(0);
617
707
  }
618
708
  return;
619
709
  }
@@ -693,6 +783,54 @@ function App() {
693
783
  return; // Ignore other keys during session picker
694
784
  }
695
785
 
786
+ // Delete session confirmation (y/n)
787
+ if (deleteSessionConfirm) {
788
+ if (inputChar === "y" || inputChar === "Y") {
789
+ const deleted = deleteSession(deleteSessionConfirm.id);
790
+ if (deleted) {
791
+ addMsg("info", `✅ Deleted session ${deleteSessionConfirm.id}`);
792
+ } else {
793
+ addMsg("error", `Failed to delete session ${deleteSessionConfirm.id}`);
794
+ }
795
+ setDeleteSessionConfirm(null);
796
+ return;
797
+ }
798
+ if (inputChar === "n" || inputChar === "N" || key.escape) {
799
+ addMsg("info", "Delete cancelled.");
800
+ setDeleteSessionConfirm(null);
801
+ return;
802
+ }
803
+ return;
804
+ }
805
+
806
+ // Delete session picker navigation
807
+ if (deleteSessionPicker) {
808
+ if (key.upArrow) {
809
+ setDeleteSessionPickerIndex((prev) => (prev - 1 + deleteSessionPicker.length) % deleteSessionPicker.length);
810
+ return;
811
+ }
812
+ if (key.downArrow) {
813
+ setDeleteSessionPickerIndex((prev) => (prev + 1) % deleteSessionPicker.length);
814
+ return;
815
+ }
816
+ if (key.return) {
817
+ const selected = deleteSessionPicker[deleteSessionPickerIndex];
818
+ if (selected) {
819
+ setDeleteSessionPicker(null);
820
+ setDeleteSessionPickerIndex(0);
821
+ setDeleteSessionConfirm(selected);
822
+ }
823
+ return;
824
+ }
825
+ if (key.escape) {
826
+ setDeleteSessionPicker(null);
827
+ setDeleteSessionPickerIndex(0);
828
+ addMsg("info", "Delete cancelled.");
829
+ return;
830
+ }
831
+ return;
832
+ }
833
+
696
834
  // Backspace with empty input → remove last paste chunk
697
835
  if (key.backspace || key.delete) {
698
836
  if (input === "" && pastedChunksRef.current.length > 0) {
@@ -844,6 +982,21 @@ function App() {
844
982
  {approval.tool === "write_file" && approval.args.content ? (
845
983
  <Text color={theme.colors.muted}>{" "}{String(approval.args.content).split("\n").length}{" lines, "}{String(approval.args.content).length}{"B"}</Text>
846
984
  ) : null}
985
+ {approval.diff ? (
986
+ <Box flexDirection="column" marginTop={0} marginLeft={2}>
987
+ {approval.diff.split("\n").slice(0, 40).map((line, i) => (
988
+ <Text key={i} color={
989
+ line.startsWith("+") ? theme.colors.success :
990
+ line.startsWith("-") ? theme.colors.error :
991
+ line.startsWith("@@") ? theme.colors.primary :
992
+ theme.colors.muted
993
+ }>{line}</Text>
994
+ ))}
995
+ {approval.diff.split("\n").length > 40 ? (
996
+ <Text color={theme.colors.muted}>... ({approval.diff.split("\n").length - 40} more lines)</Text>
997
+ ) : null}
998
+ </Box>
999
+ ) : null}
847
1000
  {approval.tool === "run_command" && approval.args.command ? (
848
1001
  <Text color={theme.colors.muted}>{" $ "}{String(approval.args.command)}</Text>
849
1002
  ) : null}
@@ -855,6 +1008,45 @@ function App() {
855
1008
  </Box>
856
1009
  )}
857
1010
 
1011
+ {/* ═══ LOGIN PICKER ═══ */}
1012
+ {loginPicker && (
1013
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.colors.border} paddingX={1} marginBottom={0}>
1014
+ <Text bold color={theme.colors.secondary}>💪 Choose a provider:</Text>
1015
+ {PROVIDERS.filter((p) => p.id !== "local").map((p, i) => (
1016
+ <Text key={p.id}>
1017
+ {i === loginPickerIndex ? <Text color={theme.colors.suggestion} bold>{"▸ "}</Text> : <Text>{" "}</Text>}
1018
+ <Text color={i === loginPickerIndex ? theme.colors.suggestion : theme.colors.primary} bold>{p.name}</Text>
1019
+ <Text color={theme.colors.muted}>{" — "}{p.description}</Text>
1020
+ {getCredentials().some((c) => c.provider === p.id) ? <Text color={theme.colors.success}> ✓</Text> : null}
1021
+ </Text>
1022
+ ))}
1023
+ <Text dimColor>{" ↑↓ navigate · Enter select · Esc cancel"}</Text>
1024
+ </Box>
1025
+ )}
1026
+
1027
+ {/* ═══ LOGIN METHOD PICKER ═══ */}
1028
+ {loginMethodPicker && (
1029
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.colors.border} paddingX={1} marginBottom={0}>
1030
+ <Text bold color={theme.colors.secondary}>How do you want to authenticate?</Text>
1031
+ {loginMethodPicker.methods.map((method, i) => {
1032
+ const labels: Record<string, string> = {
1033
+ "oauth": "🌐 Browser login (OAuth)",
1034
+ "setup-token": "🔑 Link subscription (via Claude Code CLI)",
1035
+ "cached-token": "📦 Import from existing CLI",
1036
+ "api-key": "🔒 Enter API key manually",
1037
+ "device-flow": "📱 Device flow (GitHub)",
1038
+ };
1039
+ return (
1040
+ <Text key={method}>
1041
+ {i === loginMethodIndex ? <Text color={theme.colors.suggestion} bold>{"▸ "}</Text> : <Text>{" "}</Text>}
1042
+ <Text color={i === loginMethodIndex ? theme.colors.suggestion : theme.colors.primary} bold>{labels[method] ?? method}</Text>
1043
+ </Text>
1044
+ );
1045
+ })}
1046
+ <Text dimColor>{" ↑↓ navigate · Enter select · Esc back"}</Text>
1047
+ </Box>
1048
+ )}
1049
+
858
1050
  {/* ═══ THEME PICKER ═══ */}
859
1051
  {themePicker && (
860
1052
  <Box flexDirection="column" borderStyle="single" borderColor={theme.colors.border} paddingX={1} marginBottom={0}>
@@ -885,6 +1077,32 @@ function App() {
885
1077
  </Box>
886
1078
  )}
887
1079
 
1080
+ {/* ═══ DELETE SESSION PICKER ═══ */}
1081
+ {deleteSessionPicker && (
1082
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.colors.error} paddingX={1} marginBottom={0}>
1083
+ <Text bold color={theme.colors.error}>Delete a session:</Text>
1084
+ {deleteSessionPicker.map((s, i) => (
1085
+ <Text key={s.id}>
1086
+ {i === deleteSessionPickerIndex ? <Text color={theme.colors.suggestion} bold>{"▸ "}</Text> : <Text>{" "}</Text>}
1087
+ <Text color={i === deleteSessionPickerIndex ? theme.colors.suggestion : theme.colors.muted}>{s.display}</Text>
1088
+ </Text>
1089
+ ))}
1090
+ <Text dimColor>{" ↑↓ navigate · Enter select · Esc cancel"}</Text>
1091
+ </Box>
1092
+ )}
1093
+
1094
+ {/* ═══ DELETE SESSION CONFIRM ═══ */}
1095
+ {deleteSessionConfirm && (
1096
+ <Box flexDirection="column" borderStyle="single" borderColor={theme.colors.warning} paddingX={1} marginBottom={0}>
1097
+ <Text bold color={theme.colors.warning}>Delete session {deleteSessionConfirm.id}?</Text>
1098
+ <Text color={theme.colors.muted}>{" "}{deleteSessionConfirm.display}</Text>
1099
+ <Text>
1100
+ <Text color={theme.colors.error} bold> [y]</Text><Text>es </Text>
1101
+ <Text color={theme.colors.success} bold>[n]</Text><Text>o</Text>
1102
+ </Text>
1103
+ </Box>
1104
+ )}
1105
+
888
1106
  {/* ═══ COMMAND SUGGESTIONS ═══ */}
889
1107
  {showSuggestions && (
890
1108
  <Box flexDirection="column" borderStyle="single" borderColor={theme.colors.muted} paddingX={1} marginBottom={0}>
@@ -931,6 +1149,13 @@ function App() {
931
1149
  return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
932
1150
  })()}
933
1151
  {" tokens"}
1152
+ {(() => {
1153
+ const { totalCost } = agent.getCostInfo();
1154
+ if (totalCost > 0) {
1155
+ return ` · 💰 $${totalCost < 0.01 ? totalCost.toFixed(4) : totalCost.toFixed(2)}`;
1156
+ }
1157
+ return "";
1158
+ })()}
934
1159
  {modelName ? ` · 🤖 ${modelName}` : ""}
935
1160
  </Text>
936
1161
  </Box>
@@ -177,6 +177,133 @@ export async function executeTool(
177
177
  }
178
178
  }
179
179
 
180
+ /**
181
+ * Generate a simple unified diff between two strings
182
+ */
183
+ export function generateDiff(oldContent: string, newContent: string, filePath: string): string {
184
+ const oldLines = oldContent.split("\n");
185
+ const newLines = newContent.split("\n");
186
+ const output: string[] = [`--- a/${filePath}`, `+++ b/${filePath}`];
187
+
188
+ const lcs = computeLCS(oldLines, newLines);
189
+
190
+ let oi = 0, ni = 0, li = 0;
191
+ let hunkLines: string[] = [];
192
+ let hunkOldCount = 0;
193
+ let hunkNewCount = 0;
194
+ let hunkStartOld = 1;
195
+ let hunkStartNew = 1;
196
+ let pendingContext: string[] = [];
197
+ let hasHunk = false;
198
+
199
+ function flushHunk() {
200
+ if (hasHunk && hunkLines.length > 0) {
201
+ output.push(`@@ -${hunkStartOld},${hunkOldCount} +${hunkStartNew},${hunkNewCount} @@`);
202
+ output.push(...hunkLines);
203
+ }
204
+ hunkLines = [];
205
+ hunkOldCount = 0;
206
+ hunkNewCount = 0;
207
+ hasHunk = false;
208
+ pendingContext = [];
209
+ }
210
+
211
+ function startHunk() {
212
+ if (!hasHunk) {
213
+ hasHunk = true;
214
+ hunkStartOld = Math.max(1, oi + 1 - 3);
215
+ hunkStartNew = Math.max(1, ni + 1 - 3);
216
+ const contextStart = Math.max(0, oi - 3);
217
+ for (let c = contextStart; c < oi; c++) {
218
+ hunkLines.push(` ${oldLines[c]}`);
219
+ hunkOldCount++;
220
+ hunkNewCount++;
221
+ }
222
+ }
223
+ if (pendingContext.length > 0) {
224
+ hunkLines.push(...pendingContext);
225
+ pendingContext = [];
226
+ }
227
+ }
228
+
229
+ while (oi < oldLines.length || ni < newLines.length) {
230
+ if (li < lcs.length && oi < oldLines.length && ni < newLines.length &&
231
+ oldLines[oi] === lcs[li] && newLines[ni] === lcs[li]) {
232
+ // Matching line
233
+ if (hasHunk) {
234
+ pendingContext.push(` ${oldLines[oi]}`);
235
+ hunkOldCount++;
236
+ hunkNewCount++;
237
+ if (pendingContext.length > 6) flushHunk();
238
+ }
239
+ oi++; ni++; li++;
240
+ } else if (oi < oldLines.length && (li >= lcs.length || oldLines[oi] !== lcs[li])) {
241
+ startHunk();
242
+ hunkLines.push(`-${oldLines[oi]}`);
243
+ hunkOldCount++;
244
+ oi++;
245
+ } else if (ni < newLines.length && (li >= lcs.length || newLines[ni] !== lcs[li])) {
246
+ startHunk();
247
+ hunkLines.push(`+${newLines[ni]}`);
248
+ hunkNewCount++;
249
+ ni++;
250
+ } else {
251
+ break;
252
+ }
253
+ }
254
+
255
+ flushHunk();
256
+
257
+ if (output.length <= 2) return "(no changes)";
258
+
259
+ const maxDiffLines = 60;
260
+ if (output.length > maxDiffLines + 2) {
261
+ return output.slice(0, maxDiffLines + 2).join("\n") + `\n... (${output.length - maxDiffLines - 2} more lines)`;
262
+ }
263
+ return output.join("\n");
264
+ }
265
+
266
+ function computeLCS(a: string[], b: string[]): string[] {
267
+ if (a.length > 500 || b.length > 500) {
268
+ // For large files, just return common lines in order
269
+ const result: string[] = [];
270
+ let bi = 0;
271
+ for (const line of a) {
272
+ while (bi < b.length && b[bi] !== line) bi++;
273
+ if (bi < b.length) { result.push(line); bi++; }
274
+ }
275
+ return result;
276
+ }
277
+ const m = a.length, n = b.length;
278
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
279
+ for (let i = 1; i <= m; i++) {
280
+ for (let j = 1; j <= n; j++) {
281
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]);
282
+ }
283
+ }
284
+ const result: string[] = [];
285
+ let i = m, j = n;
286
+ while (i > 0 && j > 0) {
287
+ if (a[i - 1] === b[j - 1]) { result.unshift(a[i - 1]); i--; j--; }
288
+ else if (dp[i - 1][j] > dp[i][j - 1]) { i--; }
289
+ else { j--; }
290
+ }
291
+ return result;
292
+ }
293
+
294
+ /**
295
+ * Get existing file content for diff preview (returns null if file doesn't exist)
296
+ */
297
+ export function getExistingContent(filePath: string, cwd: string): string | null {
298
+ const fullPath = join(cwd, filePath);
299
+ if (!existsSync(fullPath)) return null;
300
+ try {
301
+ return readFileSync(fullPath, "utf-8");
302
+ } catch {
303
+ return null;
304
+ }
305
+ }
306
+
180
307
  function listDir(
181
308
  dirPath: string,
182
309
  cwd: string,
@@ -34,7 +34,10 @@ function getDb(): Database.Database {
34
34
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
35
35
  message_count INTEGER NOT NULL DEFAULT 0,
36
36
  token_estimate INTEGER NOT NULL DEFAULT 0,
37
- summary TEXT
37
+ summary TEXT,
38
+ prompt_tokens INTEGER NOT NULL DEFAULT 0,
39
+ completion_tokens INTEGER NOT NULL DEFAULT 0,
40
+ estimated_cost REAL NOT NULL DEFAULT 0
38
41
  );
39
42
 
40
43
  CREATE TABLE IF NOT EXISTS messages (
@@ -51,6 +54,17 @@ function getDb(): Database.Database {
51
54
  CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
52
55
  `);
53
56
 
57
+ // Migrate: add cost columns if missing (for existing DBs)
58
+ try {
59
+ db.exec(`ALTER TABLE sessions ADD COLUMN prompt_tokens INTEGER NOT NULL DEFAULT 0`);
60
+ } catch { /* column already exists */ }
61
+ try {
62
+ db.exec(`ALTER TABLE sessions ADD COLUMN completion_tokens INTEGER NOT NULL DEFAULT 0`);
63
+ } catch { /* column already exists */ }
64
+ try {
65
+ db.exec(`ALTER TABLE sessions ADD COLUMN estimated_cost REAL NOT NULL DEFAULT 0`);
66
+ } catch { /* column already exists */ }
67
+
54
68
  return db;
55
69
  }
56
70
 
@@ -129,6 +143,9 @@ export interface SessionInfo {
129
143
  message_count: number;
130
144
  token_estimate: number;
131
145
  summary: string | null;
146
+ prompt_tokens: number;
147
+ completion_tokens: number;
148
+ estimated_cost: number;
132
149
  }
133
150
 
134
151
  export function listSessions(limit: number = 10): SessionInfo[] {
@@ -191,6 +208,21 @@ export function deleteSession(sessionId: string): boolean {
191
208
  return result.changes > 0;
192
209
  }
193
210
 
211
+ /**
212
+ * Update cost tracking for a session
213
+ */
214
+ export function updateSessionCost(
215
+ sessionId: string,
216
+ promptTokens: number,
217
+ completionTokens: number,
218
+ estimatedCost: number
219
+ ): void {
220
+ const db = getDb();
221
+ db.prepare(`
222
+ UPDATE sessions SET prompt_tokens = ?, completion_tokens = ?, estimated_cost = ? WHERE id = ?
223
+ `).run(promptTokens, completionTokens, estimatedCost, sessionId);
224
+ }
225
+
194
226
  /**
195
227
  * Update session summary (for context compression)
196
228
  */