Skip to content

Commit 946b8a5

Browse files
matt2eclaude
andauthored
fix(staged): improve action output with PTY support and carriage return handling (#557)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 79ebcca commit 946b8a5

8 files changed

Lines changed: 413 additions & 88 deletions

File tree

apps/staged/justfile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,15 +150,19 @@ lint:
150150
test:
151151
cd src-tauri && cargo test
152152

153+
# Run frontend unit tests
154+
test-frontend:
155+
pnpm test
156+
153157
# Type-check frontend (Svelte + TypeScript)
154158
typecheck:
155159
pnpm run check
156160

157161
# Format, then verify everything passes — run before pushing
158-
check-all: fmt lint typecheck test
162+
check-all: fmt lint typecheck test test-frontend
159163

160164
# Verify everything without modifying files — for CI
161-
ci: fmt-check lint typecheck test
165+
ci: fmt-check lint typecheck test test-frontend
162166

163167
# ============================================================================
164168
# Maintenance

apps/staged/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"tauri:build": "tauri build",
1414
"tauri:release:config": "node scripts/build-tauri-release-config.mjs",
1515
"release:updater:publish": "node scripts/publish-updater-to-github-release.mjs",
16-
"release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs"
16+
"release:dmg:publish": "node scripts/publish-dmg-to-github-release.mjs",
17+
"test": "vitest run",
18+
"test:watch": "vitest"
1719
},
1820
"devDependencies": {
1921
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -25,7 +27,8 @@
2527
"svelte": "^5.46.4",
2628
"svelte-check": "^4.3.4",
2729
"typescript": "~5.9.3",
28-
"vite": "^7.2.4"
30+
"vite": "^7.2.4",
31+
"vitest": "^4.0.18"
2932
},
3033
"dependencies": {
3134
"@builderbot/diff-viewer": "workspace:*",

apps/staged/src/lib/features/actions/ActionOutputModal.svelte

Lines changed: 1 addition & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
listenToActionOutput,
4343
listenToActionStatus,
4444
} from './actions';
45+
import { processChunksToLines, type TerminalLine } from './processOutput';
4546
4647
interface Props {
4748
executionId: string;
@@ -67,12 +68,6 @@
6768
// State
6869
// =========================================================================
6970
70-
/** A processed terminal line ready for display. */
71-
interface TerminalLine {
72-
text: string;
73-
stream: 'stdout' | 'stderr';
74-
}
75-
7671
let status = $state<ActionStatus>('running');
7772
let exitCode = $state<number | null>(null);
7873
let outputChunks = $state<OutputChunk[]>([]);
@@ -151,60 +146,6 @@
151146
}
152147
}
153148
154-
/**
155-
* Process raw output chunks into terminal lines, handling carriage returns.
156-
*
157-
* Terminal programs use \r (carriage return without newline) to overwrite the
158-
* current line in-place — e.g. for progress bars. This function simulates
159-
* that behavior:
160-
* - \n finalizes the current line and starts a new one
161-
* - \r (not followed by \n) resets the cursor to the start of the current
162-
* line so subsequent text overwrites it
163-
*/
164-
function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] {
165-
const lines: TerminalLine[] = [];
166-
let currentText = '';
167-
let currentStream: 'stdout' | 'stderr' = 'stdout';
168-
169-
for (const chunk of chunks) {
170-
const raw = chunk.chunk;
171-
const stream = chunk.stream;
172-
173-
for (let i = 0; i < raw.length; i++) {
174-
const ch = raw[i];
175-
176-
if (ch === '\n') {
177-
// Newline: finalize the current line and start a new one
178-
lines.push({ text: currentText, stream: currentStream });
179-
currentText = '';
180-
currentStream = stream;
181-
} else if (ch === '\r') {
182-
// Carriage return: check if it's \r\n (treat as plain newline)
183-
if (i + 1 < raw.length && raw[i + 1] === '\n') {
184-
lines.push({ text: currentText, stream: currentStream });
185-
currentText = '';
186-
currentStream = stream;
187-
i++; // skip the \n
188-
} else {
189-
// Bare \r: reset cursor to start of current line (overwrite)
190-
currentText = '';
191-
currentStream = stream;
192-
}
193-
} else {
194-
currentText += ch;
195-
currentStream = stream;
196-
}
197-
}
198-
}
199-
200-
// Don't forget the last in-progress line
201-
if (currentText.length > 0) {
202-
lines.push({ text: currentText, stream: currentStream });
203-
}
204-
205-
return lines;
206-
}
207-
208149
/** Derived display lines — recomputed whenever outputChunks changes. */
209150
let displayLines = $derived(processChunksToLines(outputChunks));
210151
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { processChunksToLines } from './processOutput';
3+
import type { OutputChunk } from './actions';
4+
5+
/** Helper to build an OutputChunk. */
6+
function chunk(text: string, stream: 'stdout' | 'stderr' = 'stdout'): OutputChunk {
7+
return { chunk: text, stream, timestamp: 0 };
8+
}
9+
10+
/** Helper to extract just the text from result lines. */
11+
function texts(chunks: OutputChunk[]): string[] {
12+
return processChunksToLines(chunks).map((l) => l.text);
13+
}
14+
15+
describe('processChunksToLines', () => {
16+
// ---------------------------------------------------------------------------
17+
// Basic newline handling
18+
// ---------------------------------------------------------------------------
19+
20+
it('splits on \\n', () => {
21+
expect(texts([chunk('hello\nworld\n')])).toEqual(['hello', 'world']);
22+
});
23+
24+
it('keeps a trailing line that has no terminating newline', () => {
25+
expect(texts([chunk('hello\nworld')])).toEqual(['hello', 'world']);
26+
});
27+
28+
it('handles empty input', () => {
29+
expect(texts([])).toEqual([]);
30+
});
31+
32+
it('handles a single chunk with no newlines', () => {
33+
expect(texts([chunk('hello')])).toEqual(['hello']);
34+
});
35+
36+
// ---------------------------------------------------------------------------
37+
// \\r\\n (CRLF) handling
38+
// ---------------------------------------------------------------------------
39+
40+
it('treats \\r\\n as a single newline', () => {
41+
expect(texts([chunk('hello\r\nworld\r\n')])).toEqual(['hello', 'world']);
42+
});
43+
44+
it('handles \\r\\n split across two chunks', () => {
45+
expect(texts([chunk('hello\r'), chunk('\nworld')])).toEqual(['hello', 'world']);
46+
});
47+
48+
// ---------------------------------------------------------------------------
49+
// Bare \\r (carriage return) — progress bar behavior
50+
// ---------------------------------------------------------------------------
51+
52+
it('bare \\r overwrites the current line (single chunk)', () => {
53+
expect(texts([chunk('progress 50%\rprogress 100%\n')])).toEqual(['progress 100%']);
54+
});
55+
56+
it('bare \\r overwrites across multiple updates in one chunk', () => {
57+
expect(texts([chunk('10%\r20%\r30%\r40%\n')])).toEqual(['40%']);
58+
});
59+
60+
it('bare \\r overwrites across separate chunks', () => {
61+
expect(texts([chunk('downloading 50%\r'), chunk('downloading 100%\n')])).toEqual([
62+
'downloading 100%',
63+
]);
64+
});
65+
66+
it('bare \\r at end of chunk followed by non-\\n text in next chunk overwrites', () => {
67+
expect(texts([chunk('old text\r'), chunk('new text')])).toEqual(['new text']);
68+
});
69+
70+
it('multiple progress updates across separate chunks collapse correctly', () => {
71+
expect(texts([chunk('10%\r'), chunk('20%\r'), chunk('30%\r'), chunk('done\n')])).toEqual([
72+
'done',
73+
]);
74+
});
75+
76+
it('progress bar followed by normal output', () => {
77+
expect(texts([chunk('building...\rprogress 50%\rprogress 100%\nSuccess!\n')])).toEqual([
78+
'progress 100%',
79+
'Success!',
80+
]);
81+
});
82+
83+
// ---------------------------------------------------------------------------
84+
// Mixed scenarios
85+
// ---------------------------------------------------------------------------
86+
87+
it('handles normal lines before and after progress bars', () => {
88+
expect(
89+
texts([chunk('Starting build\n'), chunk('0%\r50%\r100%\n'), chunk('Build complete\n')])
90+
).toEqual(['Starting build', '100%', 'Build complete']);
91+
});
92+
93+
it('handles interleaved stdout and stderr', () => {
94+
const result = processChunksToLines([
95+
chunk('out line\n', 'stdout'),
96+
chunk('err line\n', 'stderr'),
97+
]);
98+
expect(result).toEqual([
99+
{ text: 'out line', stream: 'stdout' },
100+
{ text: 'err line', stream: 'stderr' },
101+
]);
102+
});
103+
104+
it('bare \\r at the very end of all chunks produces no trailing line', () => {
105+
// A progress bar that never finishes with \n — should show nothing
106+
// because the \r resets the line and there's no further content.
107+
expect(texts([chunk('in progress\r')])).toEqual([]);
108+
});
109+
110+
it('empty lines are preserved', () => {
111+
expect(texts([chunk('a\n\nb\n')])).toEqual(['a', '', 'b']);
112+
});
113+
114+
it('handles chunk boundaries mid-text', () => {
115+
expect(texts([chunk('hel'), chunk('lo\nwor'), chunk('ld\n')])).toEqual(['hello', 'world']);
116+
});
117+
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Terminal output processing utilities.
3+
*
4+
* Converts raw output chunks (as received from the backend) into display-ready
5+
* terminal lines, handling carriage returns the way a real terminal would.
6+
*/
7+
8+
import type { OutputChunk } from './actions';
9+
10+
/** A processed terminal line ready for display. */
11+
export interface TerminalLine {
12+
text: string;
13+
stream: 'stdout' | 'stderr';
14+
}
15+
16+
/**
17+
* Process raw output chunks into terminal lines, handling carriage returns.
18+
*
19+
* Terminal programs use \r (carriage return without newline) to overwrite the
20+
* current line in-place — e.g. for progress bars. This function simulates
21+
* that behavior:
22+
* - \n finalizes the current line and starts a new one
23+
* - \r\n is treated as a single newline
24+
* - \r (not followed by \n) resets the cursor to the start of the current
25+
* line so subsequent text overwrites it
26+
*
27+
* Because chunks arrive in arbitrary byte boundaries, \r\n may be split across
28+
* two consecutive chunks. We track this with `pendingCR` so the \r at the end
29+
* of one chunk and the \n at the start of the next are still treated as a
30+
* single newline.
31+
*/
32+
export function processChunksToLines(chunks: OutputChunk[]): TerminalLine[] {
33+
const lines: TerminalLine[] = [];
34+
let currentText = '';
35+
let currentStream: 'stdout' | 'stderr' = 'stdout';
36+
let pendingCR = false;
37+
38+
for (const chunk of chunks) {
39+
const raw = chunk.chunk;
40+
const stream = chunk.stream;
41+
42+
for (let i = 0; i < raw.length; i++) {
43+
const ch = raw[i];
44+
45+
if (pendingCR) {
46+
pendingCR = false;
47+
if (ch === '\n') {
48+
// \r\n split across chunks — treat as a single newline
49+
lines.push({ text: currentText, stream: currentStream });
50+
currentText = '';
51+
currentStream = stream;
52+
continue;
53+
} else {
54+
// The previous \r was a bare carriage return — reset the line
55+
currentText = '';
56+
currentStream = stream;
57+
}
58+
}
59+
60+
if (ch === '\n') {
61+
lines.push({ text: currentText, stream: currentStream });
62+
currentText = '';
63+
currentStream = stream;
64+
} else if (ch === '\r') {
65+
if (i + 1 < raw.length && raw[i + 1] === '\n') {
66+
// \r\n within the same chunk
67+
lines.push({ text: currentText, stream: currentStream });
68+
currentText = '';
69+
currentStream = stream;
70+
i++; // skip the \n
71+
} else if (i + 1 < raw.length) {
72+
// Bare \r with more data in this chunk: reset cursor (overwrite)
73+
currentText = '';
74+
currentStream = stream;
75+
} else {
76+
// \r at the very end of the chunk — defer decision until next chunk
77+
pendingCR = true;
78+
}
79+
} else {
80+
currentText += ch;
81+
currentStream = stream;
82+
}
83+
}
84+
}
85+
86+
// If the last chunk ended with a bare \r that was never resolved, treat it
87+
// as a carriage return (reset the line).
88+
if (pendingCR) {
89+
currentText = '';
90+
}
91+
92+
// Don't forget the last in-progress line
93+
if (currentText.length > 0) {
94+
lines.push({ text: currentText, stream: currentStream });
95+
}
96+
97+
return lines;
98+
}

apps/staged/vitest.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'vitest/config';
2+
3+
export default defineConfig({
4+
test: {
5+
include: ['src/**/*.test.ts'],
6+
},
7+
});

0 commit comments

Comments
 (0)