Skip to content

Commit 0672605

Browse files
committed
feat(tui): open generated files and reports in a terminal viewer
Add a clickable "Files generated" list in the chat transcript with a full-screen file viewer (Esc to return). Wire Studio artifact/report_marker SSE events and session history artifacts through the platform backend, and fetch content via GET /api/studio/artifacts/content.
1 parent e9bda83 commit 0672605

12 files changed

Lines changed: 647 additions & 22 deletions

File tree

docs/architecture/terminal-app.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ AdaptiveColor cursor-line backgrounds that break dark alt-screen UIs).
103103
| Tool activity | `pkg/tui/render.ActivitySummary` + `StatsFromSteps` (`+N/−M`); click-to-expand reveals **raw request args and response JSON** (not file diffs) |
104104
| Network authorization | Inline transcript notice plus a focused approval card for OpenShell proxy denials; `enter`/`y` allows the blocked host, `b` allows the suggested broader pattern, and `n`/`esc` denies |
105105
| File diffs | **Main-thread** `ItemFileDiff` dual-gutter editor view (`old`/`new` line numbers + ± content) on successful `edit_file`/`write_file`. Prefers `verification_context`; falls back to args |
106+
| Generated files / reports | `artifact` SSE events render as a compact “Files generated” list in the transcript. Clicking a file opens a full-screen file viewer; `Esc` returns to the main chat. Markdown artifacts render through the same terminal markdown renderer as agent responses, while other extensions render as scrollable raw/code content with line numbers. |
106107

107108
Streaming: unclosed fences render as incomplete code blocks (header shows ``).
108109

@@ -149,6 +150,7 @@ Approvals and denials call the network-grant REST endpoints directly instead of
149150
- `ctrl+l` or `/sessions` — list sessions, `enter` resume, `d` delete with confirmation, `n` new, `esc` close
150151
- `ctrl+n` or `/new` — clear local session id; next message creates a new server session
151152
- Click a tool activity block to expand/collapse detailed execution rows; `ctrl+o` toggles the latest activity
153+
- Click a generated file row to open it in the terminal file viewer; `Esc` returns to the chat thread
152154
- Drag across transcript text to select it; releasing the mouse automatically copies the selected plain text to the system clipboard
153155
- `astonish chat --resume <id>` — loads history via `GET /api/studio/sessions/{id}` on open
154156

docs/website/docs/cli/chat.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ Pasting an image from the clipboard inserts `[image #1]` (then `#2`, `#3`, …).
8888

8989
On Linux, image paste requires `wl-paste` (Wayland) or `xclip` (X11) on `PATH`. On macOS, the system pasteboard is used directly.
9090

91+
## Generated files and reports
92+
93+
When an agent creates files, the terminal transcript shows a compact **Files generated** list. Click a file row to open it in a full-screen viewer; press `Esc` to return to the main chat thread. Markdown files and reports render with formatted headings, lists, tables, and code blocks. Other file types open as scrollable raw/code content with line numbers.
94+
9195
## In-Session Commands
9296

9397
While in an active chat session, type `/` to access these commands:

pkg/client/api.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package client
22

33
import (
44
"fmt"
5+
"io"
56
"net/http"
67
"net/url"
78
)
@@ -63,9 +64,20 @@ type SessionDetail struct {
6364
CreatedAt string `json:"createdAt"`
6465
UpdatedAt string `json:"updatedAt"`
6566
Messages []StudioMessage `json:"messages"`
67+
Artifacts []ArtifactInfo `json:"artifacts,omitempty"`
6668
TotalUsage *UsageSummary `json:"totalUsage,omitempty"`
6769
}
6870

71+
// ArtifactInfo describes a generated file returned by the Studio session API.
72+
type ArtifactInfo struct {
73+
Path string `json:"path"`
74+
FileName string `json:"fileName"`
75+
FileType string `json:"fileType"`
76+
ToolName string `json:"toolName"`
77+
IsReport bool `json:"isReport,omitempty"`
78+
ReportTitle string `json:"reportTitle,omitempty"`
79+
}
80+
6981
// GetSessionDetail returns typed session history for the terminal chat app.
7082
func (c *Client) GetSessionDetail(id string) (*SessionDetail, error) {
7183
var detail SessionDetail
@@ -238,6 +250,28 @@ func (c *Client) SendChatMessage(req *ChatRequest) (*SSEStream, error) {
238250
return c.SSE("POST", "/api/studio/chat", req)
239251
}
240252

253+
// GetArtifactContent returns the plain-text content for a generated file artifact.
254+
func (c *Client) GetArtifactContent(path, sessionID string) (string, error) {
255+
params := url.Values{}
256+
params.Set("path", path)
257+
if sessionID != "" {
258+
params.Set("session", sessionID)
259+
}
260+
resp, err := c.Do("GET", "/api/studio/artifacts/content?"+params.Encode(), nil)
261+
if err != nil {
262+
return "", err
263+
}
264+
defer resp.Body.Close()
265+
if resp.StatusCode >= 400 {
266+
return "", parseErrorResponse(resp)
267+
}
268+
data, err := io.ReadAll(resp.Body)
269+
if err != nil {
270+
return "", fmt.Errorf("read artifact content: %w", err)
271+
}
272+
return string(data), nil
273+
}
274+
241275
// NetworkDenial describes a blocked outbound connection returned by Studio.
242276
type NetworkDenial struct {
243277
ChunkID string `json:"chunk_id"`

pkg/launcher/tui_chat.go

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,24 @@ func (b *platformBackend) SetModelPin(ctx context.Context, provider, model strin
322322
return effP, effM, nil
323323
}
324324

325+
func (b *platformBackend) ReadArtifactContent(ctx context.Context, sessionID, path string) (backend.ArtifactContent, error) {
326+
_ = ctx
327+
path = strings.TrimSpace(path)
328+
if path == "" {
329+
return backend.ArtifactContent{}, fmt.Errorf("artifact path required")
330+
}
331+
if sessionID == "" {
332+
b.mu.Lock()
333+
sessionID = b.sessionID
334+
b.mu.Unlock()
335+
}
336+
content, err := b.client.GetArtifactContent(path, sessionID)
337+
if err != nil {
338+
return backend.ArtifactContent{}, err
339+
}
340+
return backend.ArtifactContent{Path: path, Content: content}, nil
341+
}
342+
325343
func (b *platformBackend) NewSession() {
326344
b.mu.Lock()
327345
b.sessionID = ""
@@ -382,7 +400,7 @@ func (b *platformBackend) loadHistory(ctx context.Context, id string) ([]backend
382400
_ = detail.Title
383401
b.usage = usageFromSessionDetail(detail)
384402
b.mu.Unlock()
385-
return studioMessagesToHistory(detail.Messages), nil
403+
return studioDetailToHistory(detail), nil
386404
}
387405

388406
func usageFromSessionDetail(detail *client.SessionDetail) *events.Usage {
@@ -430,6 +448,21 @@ func addUsage(total, delta *events.Usage) *events.Usage {
430448
return total
431449
}
432450

451+
func studioDetailToHistory(detail *client.SessionDetail) []backend.HistoryEntry {
452+
if detail == nil {
453+
return nil
454+
}
455+
out := studioMessagesToHistory(detail.Messages)
456+
for _, a := range detail.Artifacts {
457+
artifact := artifactFromClient(a)
458+
if artifact.Path == "" {
459+
continue
460+
}
461+
out = append(out, backend.HistoryEntry{Kind: "artifact", Text: artifact.Path, Artifact: &artifact})
462+
}
463+
return out
464+
}
465+
433466
func studioMessagesToHistory(msgs []client.StudioMessage) []backend.HistoryEntry {
434467
out := make([]backend.HistoryEntry, 0, len(msgs))
435468
for _, m := range msgs {
@@ -471,6 +504,17 @@ func studioMessagesToHistory(msgs []client.StudioMessage) []backend.HistoryEntry
471504
return out
472505
}
473506

507+
func artifactFromClient(a client.ArtifactInfo) events.Artifact {
508+
return events.Artifact{
509+
Path: a.Path,
510+
FileName: a.FileName,
511+
FileType: a.FileType,
512+
ToolName: a.ToolName,
513+
IsReport: a.IsReport,
514+
ReportTitle: a.ReportTitle,
515+
}
516+
}
517+
474518
func (b *platformBackend) RunTurn(ctx context.Context, message string, opts backend.TurnOptions) (<-chan events.Event, error) {
475519
b.mu.Lock()
476520
if b.closed {
@@ -752,21 +796,52 @@ func mapSSEToEvents(sev *client.SSEEvent, debug bool) []events.Event {
752796
}
753797
}
754798
}
799+
case "report_marker":
800+
var payload struct {
801+
Path string `json:"path"`
802+
Title string `json:"title"`
803+
}
804+
if json.Unmarshal(data, &payload) == nil && payload.Path != "" {
805+
artifact := events.Artifact{Path: payload.Path, IsReport: true, ReportTitle: payload.Title}
806+
return []events.Event{{
807+
Kind: events.KindReportMarker,
808+
Text: payload.Path,
809+
Artifact: &artifact,
810+
Meta: map[string]any{"path": payload.Path, "title": payload.Title},
811+
}}
812+
}
755813
case "artifact":
756814
var payload struct {
757-
Path string `json:"path"`
758-
ToolName string `json:"tool_name"`
759-
FileName string `json:"fileName"`
815+
Path string `json:"path"`
816+
ToolName string `json:"tool_name"`
817+
ToolName2 string `json:"toolName"`
818+
FileName string `json:"fileName"`
819+
FileType string `json:"fileType"`
820+
IsReport bool `json:"isReport"`
821+
ReportTitle string `json:"reportTitle"`
760822
}
761823
if json.Unmarshal(data, &payload) == nil {
762824
path := payload.Path
763825
if path == "" {
764826
path = payload.FileName
765827
}
828+
toolName := payload.ToolName
829+
if toolName == "" {
830+
toolName = payload.ToolName2
831+
}
832+
artifact := events.Artifact{
833+
Path: path,
834+
FileName: payload.FileName,
835+
FileType: payload.FileType,
836+
ToolName: toolName,
837+
IsReport: payload.IsReport,
838+
ReportTitle: payload.ReportTitle,
839+
}
766840
return []events.Event{{
767-
Kind: events.KindArtifact,
768-
Text: path,
769-
Meta: map[string]any{"path": path, "tool": payload.ToolName},
841+
Kind: events.KindArtifact,
842+
Text: path,
843+
Artifact: &artifact,
844+
Meta: map[string]any{"path": path, "tool": toolName},
770845
}}
771846
}
772847
case "error":

pkg/launcher/tui_chat_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,46 @@ func TestMapSSEToEvents_SoftDegrade(t *testing.T) {
8888
}
8989
}
9090

91+
func TestMapSSEToEvents_ArtifactMetadata(t *testing.T) {
92+
evs := mapSSEToEvents(&client.SSEEvent{
93+
Type: "artifact",
94+
Data: `{"path":"/tmp/report.md","fileName":"report.md","fileType":"Markdown","toolName":"write_file","isReport":true,"reportTitle":"Quarterly Report"}`,
95+
}, false)
96+
if len(evs) != 1 || evs[0].Kind != events.KindArtifact || evs[0].Artifact == nil {
97+
t.Fatalf("artifact: %+v", evs)
98+
}
99+
artifact := evs[0].Artifact
100+
if artifact.Path != "/tmp/report.md" || artifact.FileName != "report.md" || artifact.FileType != "Markdown" || !artifact.IsReport || artifact.ReportTitle != "Quarterly Report" {
101+
t.Fatalf("artifact metadata: %+v", artifact)
102+
}
103+
}
104+
105+
func TestMapSSEToEvents_ReportMarker(t *testing.T) {
106+
evs := mapSSEToEvents(&client.SSEEvent{
107+
Type: "report_marker",
108+
Data: `{"path":"/tmp/report.md","title":"Quarterly Report"}`,
109+
}, false)
110+
if len(evs) != 1 || evs[0].Kind != events.KindReportMarker || evs[0].Artifact == nil {
111+
t.Fatalf("report marker: %+v", evs)
112+
}
113+
if evs[0].Artifact.Path != "/tmp/report.md" || !evs[0].Artifact.IsReport || evs[0].Artifact.ReportTitle != "Quarterly Report" {
114+
t.Fatalf("report marker artifact: %+v", evs[0].Artifact)
115+
}
116+
}
117+
118+
func TestStudioDetailToHistoryIncludesArtifacts(t *testing.T) {
119+
hist := studioDetailToHistory(&client.SessionDetail{
120+
Messages: []client.StudioMessage{{Type: "agent", Content: "done"}},
121+
Artifacts: []client.ArtifactInfo{{Path: "/tmp/report.md", FileName: "report.md", FileType: "Markdown", IsReport: true}},
122+
})
123+
if len(hist) != 2 {
124+
t.Fatalf("history len=%d want 2: %+v", len(hist), hist)
125+
}
126+
if hist[1].Kind != "artifact" || hist[1].Artifact == nil || hist[1].Artifact.Path != "/tmp/report.md" {
127+
t.Fatalf("artifact history entry: %+v", hist[1])
128+
}
129+
}
130+
91131
func TestMapSSEToEvents_SkipToolBoxFrame(t *testing.T) {
92132
evs := mapSSEToEvents(&client.SSEEvent{
93133
Type: "text",

0 commit comments

Comments
 (0)