@hasna/terminal 0.7.1 → 0.7.3

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/dist/cli.js CHANGED
@@ -82,6 +82,7 @@ if (args[0] === "exec") {
82
82
  const { shouldBeLazy, toLazy, getSlice } = await import("./lazy-executor.js");
83
83
  const { parseOutput, estimateTokens } = await import("./parsers/index.js");
84
84
  const { recordSaving, recordUsage } = await import("./economy.js");
85
+ const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
85
86
  // Rewrite command if possible
86
87
  const rw = rewriteCommand(command);
87
88
  const actualCmd = rw.changed ? rw.rewritten : command;
@@ -123,6 +124,23 @@ if (args[0] === "exec") {
123
124
  console.error(`[open-terminal] showing ${slice.lines.length}/${slice.total}, ${slice.total - (offset ?? 0) - slice.lines.length} remaining`);
124
125
  process.exit(0);
125
126
  }
127
+ // Test output detection — use watchlist for structured test tracking
128
+ if (isTestOutput(clean)) {
129
+ const result = trackTests(process.cwd(), clean);
130
+ const formatted = formatWatchResult(result);
131
+ const savedTokens = rawTokens - estimateTokens(formatted);
132
+ if (savedTokens > 20)
133
+ recordSaving("structured", savedTokens);
134
+ if (jsonMode) {
135
+ console.log(JSON.stringify({ exitCode: 0, type: "test-results", ...result, duration: Date.now() - start }));
136
+ }
137
+ else {
138
+ console.log(formatted);
139
+ }
140
+ if (savedTokens > 10)
141
+ console.error(`[open-terminal] test watchlist: saved ${savedTokens} tokens`);
142
+ process.exit(0);
143
+ }
126
144
  // Lazy mode for huge output (threshold 200, skip cat/summary commands)
127
145
  if (shouldBeLazy(clean, actualCmd)) {
128
146
  const lazy = toLazy(clean, actualCmd);
@@ -151,10 +169,30 @@ if (args[0] === "exec") {
151
169
  }
152
170
  }
153
171
  catch (e) {
154
- // Command failed — show error output
172
+ // Command failed — parse error output for structured diagnosis
155
173
  const stderr = e.stderr?.toString() ?? "";
156
174
  const stdout = e.stdout?.toString() ?? "";
157
- console.log(stripNoise(stripAnsi(stdout + stderr)).cleaned);
175
+ const errorOutput = stripNoise(stripAnsi(stdout + stderr)).cleaned;
176
+ // Try structured error parsing
177
+ const { errorParser } = await import("./parsers/errors.js");
178
+ if (errorOutput.length > 200 && errorParser.detect(actualCmd, errorOutput)) {
179
+ const info = errorParser.parse(actualCmd, errorOutput);
180
+ if (jsonMode) {
181
+ console.log(JSON.stringify({ exitCode: e.status ?? 1, error: info }));
182
+ }
183
+ else {
184
+ console.log(`Error: ${info.type}`);
185
+ console.log(` ${info.message}`);
186
+ if (info.file)
187
+ console.log(` File: ${info.file}${info.line ? `:${info.line}` : ""}`);
188
+ if (info.suggestion)
189
+ console.log(` Fix: ${info.suggestion}`);
190
+ }
191
+ }
192
+ else {
193
+ // Short error or no parser match — pass through cleaned
194
+ console.log(errorOutput);
195
+ }
158
196
  process.exit(e.status ?? 1);
159
197
  }
160
198
  process.exit(0);
@@ -7,6 +7,19 @@ export const gitLogParser = {
7
7
  parse(_command, output) {
8
8
  const entries = [];
9
9
  const lines = output.split("\n");
10
+ // Detect oneline format: "abc1234 commit message"
11
+ const firstLine = lines[0]?.trim() ?? "";
12
+ const isOneline = /^[a-f0-9]{7,12}\s+/.test(firstLine) && !firstLine.startsWith("commit ");
13
+ if (isOneline) {
14
+ for (const line of lines) {
15
+ const match = line.trim().match(/^([a-f0-9]{7,12})\s+(.+)$/);
16
+ if (match) {
17
+ entries.push({ hash: match[1], author: "", date: "", message: match[2] });
18
+ }
19
+ }
20
+ return entries;
21
+ }
22
+ // Verbose format
10
23
  let hash = "", author = "", date = "", message = [];
11
24
  for (const line of lines) {
12
25
  const commitMatch = line.match(/^commit\s+([a-f0-9]+)/);
@@ -0,0 +1,127 @@
1
+ // Test focus tracker — tracks test status across runs, only reports changes
2
+ // Instead of showing "248 passed, 2 failed" every time, shows:
3
+ // "auth.login: FIXED, auth.logout: STILL FAILING, 246 unchanged"
4
+ // Per-cwd watchlist
5
+ const watchlists = new Map();
6
+ /** Extract test names and status from test runner output (any runner) */
7
+ function extractTests(output) {
8
+ const tests = [];
9
+ const lines = output.split("\n");
10
+ for (let i = 0; i < lines.length; i++) {
11
+ const line = lines[i];
12
+ // PASS/FAIL with test name: "PASS src/auth.test.ts" or "✓ login works" or "✗ logout fails"
13
+ const passMatch = line.match(/(?:PASS|✓|✔|✅)\s+(.+)/);
14
+ if (passMatch) {
15
+ tests.push({ name: passMatch[1].trim(), status: "pass" });
16
+ continue;
17
+ }
18
+ const failMatch = line.match(/(?:FAIL|✗|✕|❌|×)\s+(.+)/);
19
+ if (failMatch) {
20
+ // Capture error from next few lines
21
+ const errorLines = [];
22
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
23
+ if (lines[j].match(/(?:PASS|FAIL|✓|✗|✔|✕|Tests:|^\s*$)/))
24
+ break;
25
+ errorLines.push(lines[j].trim());
26
+ }
27
+ tests.push({ name: failMatch[1].trim(), status: "fail", error: errorLines.join(" ").slice(0, 200) });
28
+ continue;
29
+ }
30
+ // Jest/vitest style: " ● test name" for failures
31
+ const jestFail = line.match(/^\s*●\s+(.+)/);
32
+ if (jestFail) {
33
+ tests.push({ name: jestFail[1].trim(), status: "fail" });
34
+ continue;
35
+ }
36
+ }
37
+ return tests;
38
+ }
39
+ /** Detect if output looks like test runner output */
40
+ export function isTestOutput(output) {
41
+ const markers = /(?:Tests?:|PASS|FAIL|✓|✗|passed|failed|\d+\s+pass|\d+\s+fail)/i;
42
+ const lines = output.split("\n");
43
+ return lines.filter(l => markers.test(l)).length >= 2;
44
+ }
45
+ /** Track test results and return only changes */
46
+ export function trackTests(cwd, output) {
47
+ const current = extractTests(output);
48
+ const prev = watchlists.get(cwd);
49
+ // Count totals from raw output (more reliable than extracted tests)
50
+ let totalPassed = 0, totalFailed = 0;
51
+ const summaryMatch = output.match(/(\d+)\s+pass/i);
52
+ const failMatch = output.match(/(\d+)\s+fail/i);
53
+ if (summaryMatch)
54
+ totalPassed = parseInt(summaryMatch[1]);
55
+ if (failMatch)
56
+ totalFailed = parseInt(failMatch[1]);
57
+ // Fallback to extracted counts
58
+ if (totalPassed === 0)
59
+ totalPassed = current.filter(t => t.status === "pass").length;
60
+ if (totalFailed === 0)
61
+ totalFailed = current.filter(t => t.status === "fail").length;
62
+ // Store current for next comparison
63
+ const currentMap = new Map();
64
+ for (const t of current)
65
+ currentMap.set(t.name, t);
66
+ watchlists.set(cwd, currentMap);
67
+ // First run — no comparison possible
68
+ if (!prev) {
69
+ return {
70
+ changed: [],
71
+ newTests: current.filter(t => t.status === "fail"), // only show failures on first run
72
+ totalPassed,
73
+ totalFailed,
74
+ unchangedCount: 0,
75
+ firstRun: true,
76
+ };
77
+ }
78
+ // Compare with previous
79
+ const changed = [];
80
+ const newTests = [];
81
+ let unchangedCount = 0;
82
+ for (const [name, test] of currentMap) {
83
+ const prevTest = prev.get(name);
84
+ if (!prevTest) {
85
+ newTests.push(test);
86
+ }
87
+ else if (prevTest.status !== test.status) {
88
+ changed.push({ name, from: prevTest.status, to: test.status, error: test.error });
89
+ }
90
+ else {
91
+ unchangedCount++;
92
+ }
93
+ }
94
+ return { changed, newTests, totalPassed, totalFailed, unchangedCount, firstRun: false };
95
+ }
96
+ /** Format watchlist result for display */
97
+ export function formatWatchResult(result) {
98
+ const lines = [];
99
+ if (result.firstRun) {
100
+ lines.push(`${result.totalPassed} passed, ${result.totalFailed} failed`);
101
+ if (result.newTests.length > 0) {
102
+ for (const t of result.newTests) {
103
+ lines.push(` ✗ ${t.name}${t.error ? `: ${t.error}` : ""}`);
104
+ }
105
+ }
106
+ return lines.join("\n");
107
+ }
108
+ // Status changes
109
+ for (const c of result.changed) {
110
+ if (c.to === "pass")
111
+ lines.push(` ✓ FIXED: ${c.name}`);
112
+ else
113
+ lines.push(` ✗ BROKE: ${c.name}${c.error ? ` — ${c.error}` : ""}`);
114
+ }
115
+ // New failures
116
+ for (const t of result.newTests.filter(t => t.status === "fail")) {
117
+ lines.push(` ✗ NEW FAIL: ${t.name}${t.error ? ` — ${t.error}` : ""}`);
118
+ }
119
+ // Summary
120
+ if (result.changed.length === 0 && result.newTests.filter(t => t.status === "fail").length === 0) {
121
+ lines.push(`✓ ${result.totalPassed} passed, ${result.totalFailed} failed (no changes)`);
122
+ }
123
+ else {
124
+ lines.push(`${result.totalPassed} passed, ${result.totalFailed} failed, ${result.unchangedCount} unchanged`);
125
+ }
126
+ return lines.join("\n");
127
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.tsx CHANGED
@@ -86,6 +86,7 @@ if (args[0] === "exec") {
86
86
  const { shouldBeLazy, toLazy, getSlice } = await import("./lazy-executor.js");
87
87
  const { parseOutput, estimateTokens } = await import("./parsers/index.js");
88
88
  const { recordSaving, recordUsage } = await import("./economy.js");
89
+ const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
89
90
 
90
91
  // Rewrite command if possible
91
92
  const rw = rewriteCommand(command);
@@ -127,6 +128,21 @@ if (args[0] === "exec") {
127
128
  process.exit(0);
128
129
  }
129
130
 
131
+ // Test output detection — use watchlist for structured test tracking
132
+ if (isTestOutput(clean)) {
133
+ const result = trackTests(process.cwd(), clean);
134
+ const formatted = formatWatchResult(result);
135
+ const savedTokens = rawTokens - estimateTokens(formatted);
136
+ if (savedTokens > 20) recordSaving("structured", savedTokens);
137
+ if (jsonMode) {
138
+ console.log(JSON.stringify({ exitCode: 0, type: "test-results", ...result, duration: Date.now() - start }));
139
+ } else {
140
+ console.log(formatted);
141
+ }
142
+ if (savedTokens > 10) console.error(`[open-terminal] test watchlist: saved ${savedTokens} tokens`);
143
+ process.exit(0);
144
+ }
145
+
130
146
  // Lazy mode for huge output (threshold 200, skip cat/summary commands)
131
147
  if (shouldBeLazy(clean, actualCmd)) {
132
148
  const lazy = toLazy(clean, actualCmd);
@@ -155,10 +171,27 @@ if (args[0] === "exec") {
155
171
  console.error(`[open-terminal] saved ${savedTokens} tokens (noise filter)`);
156
172
  }
157
173
  } catch (e: any) {
158
- // Command failed — show error output
174
+ // Command failed — parse error output for structured diagnosis
159
175
  const stderr = e.stderr?.toString() ?? "";
160
176
  const stdout = e.stdout?.toString() ?? "";
161
- console.log(stripNoise(stripAnsi(stdout + stderr)).cleaned);
177
+ const errorOutput = stripNoise(stripAnsi(stdout + stderr)).cleaned;
178
+
179
+ // Try structured error parsing
180
+ const { errorParser } = await import("./parsers/errors.js");
181
+ if (errorOutput.length > 200 && errorParser.detect(actualCmd, errorOutput)) {
182
+ const info = errorParser.parse(actualCmd, errorOutput);
183
+ if (jsonMode) {
184
+ console.log(JSON.stringify({ exitCode: e.status ?? 1, error: info }));
185
+ } else {
186
+ console.log(`Error: ${info.type}`);
187
+ console.log(` ${info.message}`);
188
+ if (info.file) console.log(` File: ${info.file}${info.line ? `:${info.line}` : ""}`);
189
+ if (info.suggestion) console.log(` Fix: ${info.suggestion}`);
190
+ }
191
+ } else {
192
+ // Short error or no parser match — pass through cleaned
193
+ console.log(errorOutput);
194
+ }
162
195
  process.exit(e.status ?? 1);
163
196
  }
164
197
  process.exit(0);
@@ -13,6 +13,21 @@ export const gitLogParser: Parser<GitLogEntry[]> = {
13
13
  const entries: GitLogEntry[] = [];
14
14
  const lines = output.split("\n");
15
15
 
16
+ // Detect oneline format: "abc1234 commit message"
17
+ const firstLine = lines[0]?.trim() ?? "";
18
+ const isOneline = /^[a-f0-9]{7,12}\s+/.test(firstLine) && !firstLine.startsWith("commit ");
19
+
20
+ if (isOneline) {
21
+ for (const line of lines) {
22
+ const match = line.trim().match(/^([a-f0-9]{7,12})\s+(.+)$/);
23
+ if (match) {
24
+ entries.push({ hash: match[1], author: "", date: "", message: match[2] });
25
+ }
26
+ }
27
+ return entries;
28
+ }
29
+
30
+ // Verbose format
16
31
  let hash = "", author = "", date = "", message: string[] = [];
17
32
 
18
33
  for (const line of lines) {
@@ -0,0 +1,156 @@
1
+ // Test focus tracker — tracks test status across runs, only reports changes
2
+ // Instead of showing "248 passed, 2 failed" every time, shows:
3
+ // "auth.login: FIXED, auth.logout: STILL FAILING, 246 unchanged"
4
+
5
+ export interface TestStatus {
6
+ name: string;
7
+ status: "pass" | "fail";
8
+ error?: string;
9
+ }
10
+
11
+ export interface TestWatchResult {
12
+ /** Tests that changed status since last run */
13
+ changed: { name: string; from: "pass" | "fail"; to: "pass" | "fail"; error?: string }[];
14
+ /** New tests not seen before */
15
+ newTests: TestStatus[];
16
+ /** Summary counts */
17
+ totalPassed: number;
18
+ totalFailed: number;
19
+ unchangedCount: number;
20
+ /** Whether this is the first run (no previous data) */
21
+ firstRun: boolean;
22
+ }
23
+
24
+ // Per-cwd watchlist
25
+ const watchlists = new Map<string, Map<string, TestStatus>>();
26
+
27
+ /** Extract test names and status from test runner output (any runner) */
28
+ function extractTests(output: string): TestStatus[] {
29
+ const tests: TestStatus[] = [];
30
+ const lines = output.split("\n");
31
+
32
+ for (let i = 0; i < lines.length; i++) {
33
+ const line = lines[i];
34
+
35
+ // PASS/FAIL with test name: "PASS src/auth.test.ts" or "✓ login works" or "✗ logout fails"
36
+ const passMatch = line.match(/(?:PASS|✓|✔|✅)\s+(.+)/);
37
+ if (passMatch) {
38
+ tests.push({ name: passMatch[1].trim(), status: "pass" });
39
+ continue;
40
+ }
41
+
42
+ const failMatch = line.match(/(?:FAIL|✗|✕|❌|×)\s+(.+)/);
43
+ if (failMatch) {
44
+ // Capture error from next few lines
45
+ const errorLines: string[] = [];
46
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
47
+ if (lines[j].match(/(?:PASS|FAIL|✓|✗|✔|✕|Tests:|^\s*$)/)) break;
48
+ errorLines.push(lines[j].trim());
49
+ }
50
+ tests.push({ name: failMatch[1].trim(), status: "fail", error: errorLines.join(" ").slice(0, 200) });
51
+ continue;
52
+ }
53
+
54
+ // Jest/vitest style: " ● test name" for failures
55
+ const jestFail = line.match(/^\s*●\s+(.+)/);
56
+ if (jestFail) {
57
+ tests.push({ name: jestFail[1].trim(), status: "fail" });
58
+ continue;
59
+ }
60
+ }
61
+
62
+ return tests;
63
+ }
64
+
65
+ /** Detect if output looks like test runner output */
66
+ export function isTestOutput(output: string): boolean {
67
+ const markers = /(?:Tests?:|PASS|FAIL|✓|✗|passed|failed|\d+\s+pass|\d+\s+fail)/i;
68
+ const lines = output.split("\n");
69
+ return lines.filter(l => markers.test(l)).length >= 2;
70
+ }
71
+
72
+ /** Track test results and return only changes */
73
+ export function trackTests(cwd: string, output: string): TestWatchResult {
74
+ const current = extractTests(output);
75
+ const prev = watchlists.get(cwd);
76
+
77
+ // Count totals from raw output (more reliable than extracted tests)
78
+ let totalPassed = 0, totalFailed = 0;
79
+ const summaryMatch = output.match(/(\d+)\s+pass/i);
80
+ const failMatch = output.match(/(\d+)\s+fail/i);
81
+ if (summaryMatch) totalPassed = parseInt(summaryMatch[1]);
82
+ if (failMatch) totalFailed = parseInt(failMatch[1]);
83
+ // Fallback to extracted counts
84
+ if (totalPassed === 0) totalPassed = current.filter(t => t.status === "pass").length;
85
+ if (totalFailed === 0) totalFailed = current.filter(t => t.status === "fail").length;
86
+
87
+ // Store current for next comparison
88
+ const currentMap = new Map<string, TestStatus>();
89
+ for (const t of current) currentMap.set(t.name, t);
90
+ watchlists.set(cwd, currentMap);
91
+
92
+ // First run — no comparison possible
93
+ if (!prev) {
94
+ return {
95
+ changed: [],
96
+ newTests: current.filter(t => t.status === "fail"), // only show failures on first run
97
+ totalPassed,
98
+ totalFailed,
99
+ unchangedCount: 0,
100
+ firstRun: true,
101
+ };
102
+ }
103
+
104
+ // Compare with previous
105
+ const changed: TestWatchResult["changed"] = [];
106
+ const newTests: TestStatus[] = [];
107
+ let unchangedCount = 0;
108
+
109
+ for (const [name, test] of currentMap) {
110
+ const prevTest = prev.get(name);
111
+ if (!prevTest) {
112
+ newTests.push(test);
113
+ } else if (prevTest.status !== test.status) {
114
+ changed.push({ name, from: prevTest.status, to: test.status, error: test.error });
115
+ } else {
116
+ unchangedCount++;
117
+ }
118
+ }
119
+
120
+ return { changed, newTests, totalPassed, totalFailed, unchangedCount, firstRun: false };
121
+ }
122
+
123
+ /** Format watchlist result for display */
124
+ export function formatWatchResult(result: TestWatchResult): string {
125
+ const lines: string[] = [];
126
+
127
+ if (result.firstRun) {
128
+ lines.push(`${result.totalPassed} passed, ${result.totalFailed} failed`);
129
+ if (result.newTests.length > 0) {
130
+ for (const t of result.newTests) {
131
+ lines.push(` ✗ ${t.name}${t.error ? `: ${t.error}` : ""}`);
132
+ }
133
+ }
134
+ return lines.join("\n");
135
+ }
136
+
137
+ // Status changes
138
+ for (const c of result.changed) {
139
+ if (c.to === "pass") lines.push(` ✓ FIXED: ${c.name}`);
140
+ else lines.push(` ✗ BROKE: ${c.name}${c.error ? ` — ${c.error}` : ""}`);
141
+ }
142
+
143
+ // New failures
144
+ for (const t of result.newTests.filter(t => t.status === "fail")) {
145
+ lines.push(` ✗ NEW FAIL: ${t.name}${t.error ? ` — ${t.error}` : ""}`);
146
+ }
147
+
148
+ // Summary
149
+ if (result.changed.length === 0 && result.newTests.filter(t => t.status === "fail").length === 0) {
150
+ lines.push(`✓ ${result.totalPassed} passed, ${result.totalFailed} failed (no changes)`);
151
+ } else {
152
+ lines.push(`${result.totalPassed} passed, ${result.totalFailed} failed, ${result.unchangedCount} unchanged`);
153
+ }
154
+
155
+ return lines.join("\n");
156
+ }