Skip to content

Commit 3ea4291

Browse files
authored
add console progress bar (#27)
* Add console progress bar * Detect OSC incompatible terminals * Wrap console methods
1 parent 7483c68 commit 3ea4291

File tree

2 files changed

+126
-44
lines changed

2 files changed

+126
-44
lines changed

ChatGPTExport/ConsoleFeatures.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
namespace ChatGPTExport
2+
{
3+
internal static class ConsoleFeatures
4+
{
5+
public static void StartIndeterminate()
6+
{
7+
if (enableOscTabProgress)
8+
{
9+
Console.Write("\x1b]9;4;3\x07"); // https://learn.microsoft.com/en-us/windows/terminal/tutorials/progress-bar-sequences
10+
}
11+
}
12+
13+
public static void SetProgress(int percent)
14+
{
15+
if (enableOscTabProgress)
16+
{
17+
Console.Write($"\x1b]9;4;1;{percent}\x07");
18+
}
19+
}
20+
21+
public static void ClearState()
22+
{
23+
if (enableOscTabProgress)
24+
{
25+
Console.Write("\x1b]9;4;0;\x07");
26+
}
27+
}
28+
29+
private static bool enableOscTabProgress = IsInteractiveTty() && SupportsOscTabProgress();
30+
31+
private static bool IsInteractiveTty() =>
32+
!Console.IsOutputRedirected; // avoid dumping control bytes into logs/pipes
33+
34+
private static bool SupportsOscTabProgress()
35+
{
36+
// Known-bad first
37+
var termProgram = Environment.GetEnvironmentVariable("TERM_PROGRAM") ?? "";
38+
if (termProgram.Contains("iTerm", StringComparison.OrdinalIgnoreCase)) return false; // iTerm2
39+
if (Environment.GetEnvironmentVariable("KITTY_WINDOW_ID") is not null) return false; // kitty
40+
if (termProgram.Contains("Apple_Terminal", StringComparison.OrdinalIgnoreCase)) return false; // macOS Terminal
41+
if (termProgram.Equals("vscode", StringComparison.OrdinalIgnoreCase)) return false; // VS Code integrated
42+
43+
// tmux/screen often block OSC; be conservative
44+
if (Environment.GetEnvironmentVariable("TMUX") is not null ||
45+
(Environment.GetEnvironmentVariable("TERM") ?? "").StartsWith("screen")) return false;
46+
47+
// Known-good
48+
if (Environment.GetEnvironmentVariable("WT_SESSION") is not null) return true; // Windows Terminal
49+
if (Environment.GetEnvironmentVariable("VTE_VERSION") is not null) return true; // GNOME Terminal/VTE
50+
if (Environment.GetEnvironmentVariable("WEZTERM_EXECUTABLE") is not null ||
51+
termProgram.Contains("WezTerm", StringComparison.OrdinalIgnoreCase)) return true; // WezTerm
52+
if (termProgram.Contains("Ghostty", StringComparison.OrdinalIgnoreCase)) return true; // Ghostty
53+
54+
return false; // default safe
55+
}
56+
}
57+
}

ChatGPTExport/Program.cs

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88

99
Console.OutputEncoding = System.Text.Encoding.UTF8;
1010

11+
Console.CancelKeyPress += (sender, args) =>
12+
{
13+
args.Cancel = true;
14+
ConsoleFeatures.ClearState();
15+
Environment.Exit(0);
16+
};
17+
1118
const string searchPattern = "conversations.json";
1219

1320
var sourceDirectoryOption = new Option<DirectoryInfo[]>("--source", "-s")
@@ -66,64 +73,82 @@ You can also specify multiple source directories (eg, -s dir1 -s dir2), and they
6673

6774
rootCommand.SetAction(parseResult =>
6875
{
69-
foreach (ParseError parseError in parseResult.Errors)
76+
try
7077
{
71-
Console.Error.WriteLine(parseError.Message);
72-
}
78+
foreach (ParseError parseError in parseResult.Errors)
79+
{
80+
Console.Error.WriteLine(parseError.Message);
81+
}
7382

74-
var sourceDirectoryInfos = parseResult.GetRequiredValue(sourceDirectoryOption);
75-
var fileSystem = new FileSystem();
76-
var destination = fileSystem.DirectoryInfo.Wrap(parseResult.GetRequiredValue(destinationDirectoryOption));
77-
var sources = sourceDirectoryInfos.Select(p => fileSystem.DirectoryInfo.Wrap(p));
83+
ConsoleFeatures.StartIndeterminate();
7884

79-
var conversationFiles = sources.Select(p => p.GetFiles(searchPattern, SearchOption.AllDirectories)).SelectMany(s => s).ToList();
85+
var sourceDirectoryInfos = parseResult.GetRequiredValue(sourceDirectoryOption);
86+
var fileSystem = new FileSystem();
87+
var destination = fileSystem.DirectoryInfo.Wrap(parseResult.GetRequiredValue(destinationDirectoryOption));
88+
var sources = sourceDirectoryInfos.Select(p => fileSystem.DirectoryInfo.Wrap(p));
8089

81-
var conversationsFactory = new ConversationsParser(fileSystem);
82-
var exporters = new List<IExporter>();
83-
if (parseResult.GetRequiredValue(jsonOption))
84-
{
85-
exporters.Add(new JsonExporter(fileSystem, destination));
86-
}
87-
if (parseResult.GetRequiredValue(markdownOption))
88-
{
89-
exporters.Add(new MarkdownExporter(fileSystem, destination));
90-
}
91-
var exporter = new Exporter(fileSystem, exporters);
90+
var conversationFiles = sources.Select(p => p.GetFiles(searchPattern, SearchOption.AllDirectories)).SelectMany(s => s).ToList();
9291

93-
Conversations GetConversations(IFileInfo p)
94-
{
95-
try
92+
var conversationsFactory = new ConversationsParser(fileSystem);
93+
var exporters = new List<IExporter>();
94+
if (parseResult.GetRequiredValue(jsonOption))
9695
{
97-
Console.WriteLine($"Loading conversation " + p.FullName);
98-
return conversationsFactory.GetConversations(p);
96+
exporters.Add(new JsonExporter(fileSystem, destination));
9997
}
100-
catch (Exception ex)
98+
if (parseResult.GetRequiredValue(markdownOption))
10199
{
102-
Console.Error.WriteLine($"Error parsing file: {p.FullName} {ex.Message}");
103-
return null;
100+
exporters.Add(new MarkdownExporter(fileSystem, destination));
104101
}
105-
}
102+
var exporter = new Exporter(fileSystem, exporters);
106103

107-
var directoryConversationsMap = conversationFiles
108-
.Select(file => new
104+
Conversations GetConversations(IFileInfo p)
109105
{
110-
file.Directory,
111-
Conversations = GetConversations(file)
112-
})
113-
.Where(x => x.Conversations != null)
114-
.OrderBy(x => x.Conversations.GetUpdateTime())
115-
.ToList();
116-
117-
var groupedByConversationId = directoryConversationsMap
118-
.SelectMany(entry => entry.Conversations, (entry, Conversation) => (entry.Directory, Conversation))
119-
.GroupBy(x => x.Conversation.conversation_id)
120-
.OrderBy(p => p.Key).ToList();
121-
122-
foreach (var group in groupedByConversationId)
106+
try
107+
{
108+
Console.WriteLine($"Loading conversation " + p.FullName);
109+
return conversationsFactory.GetConversations(p);
110+
}
111+
catch (Exception ex)
112+
{
113+
Console.Error.WriteLine($"Error parsing file: {p.FullName} {ex.Message}");
114+
return null;
115+
}
116+
}
117+
118+
var directoryConversationsMap = conversationFiles
119+
.Select(file => new
120+
{
121+
file.Directory,
122+
Conversations = GetConversations(file)
123+
})
124+
.Where(x => x.Conversations != null)
125+
.OrderBy(x => x.Conversations.GetUpdateTime())
126+
.ToList();
127+
128+
var groupedByConversationId = directoryConversationsMap
129+
.SelectMany(entry => entry.Conversations, (entry, Conversation) => (entry.Directory, Conversation))
130+
.GroupBy(x => x.Conversation.conversation_id)
131+
.OrderBy(p => p.Key).ToList();
132+
133+
var count = groupedByConversationId.Count;
134+
var position = 0;
135+
foreach (var group in groupedByConversationId)
136+
{
137+
var percent = (int)(position++ * 100.0 / count);
138+
ConsoleFeatures.SetProgress(percent);
139+
exporter.Process(group, destination);
140+
}
141+
}
142+
catch (Exception ex)
123143
{
124-
exporter.Process(group, destination);
144+
Console.Error.WriteLine(ex.Message);
145+
}
146+
finally
147+
{
148+
ConsoleFeatures.ClearState();
125149
}
126150
return 0;
151+
127152
});
128153

129154
var parseResult = rootCommand.Parse(args);

0 commit comments

Comments
 (0)