diff --git a/docs/changes/unreleased/1026-image-step-names-model.md b/docs/changes/unreleased/1026-image-step-names-model.md new file mode 100644 index 0000000000..765b371e40 --- /dev/null +++ b/docs/changes/unreleased/1026-image-step-names-model.md @@ -0,0 +1,20 @@ +--- +kind: added +title: a picture's step row names the image model, and opening it shows the prompt +pr: 1026 +surface: [chat] +invalidates: + - "A `generate_image` step row said nothing but the tool's name and a clock. It now reads `generate_image circle.png · seedream-5` — the file it wrote, and behind the middle dot, dim, the image model that drew it, in the short spelling (`modelui.ModelWord`) this surface uses for a model everywhere else. An unknown model draws nothing there rather than a placeholder." + - "Opening a `generate_image` step showed the picture and the path and nothing else. It now leads with what went IN, as prose: the prompt quoted and wrapped (capped at twelve lines, lifted by the usual `… N more lines`), one dim line per other input the call gave, then `drawn with ` — or `asked for best · drawn with seedream-5` when the call asked in its own words. The picture and its whole path still close the block." + - "A RUNNING `generate_image` step showed only its clock when opened. It now shows the same prompt block above the clock, the way a running `bash` call already shows its command." + - "Nothing on the wire carried which image model a call used. Nothing on the wire carries it now either: the row reads the tail of the tool's own result line, `… , generated on `, which is where the model that the request actually carried has always been written. There is no new event field and no second derivation — internal/session's `describeGeneratedImage` remains the one authority, and `internal/session/tools_image_test.go` pins that sentence's shape." + - "`generate_image` and `view_image` rows had no target at all, so no path on them was clickable. Both now draw the file they are about, resolved by the same `app.picturePath` rule the preview uses (arguments first, then the result, absolute beating relative), and the path on the row is a door like every other path on this surface." +--- + +`generate_image` is the one hand whose model a person chooses per call, so the +step row is where "which image model is this?" gets asked — and it was the one +row that could not answer. Both halves ride roads the row already draws from: the +call's arguments for what was asked, and the tool's own result for what actually +drew. The model is resolved inside the tool against the live catalog, so the +result is the only place the answer exists; a surface that showed its own default +instead would be wrong the first time a hosted task room drew a row. diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index fc983e7efa..db891011f4 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -133,6 +133,56 @@ This is the same freedom you have yourself in `/settings` → Providers, handed to aforge per call: ask it to "draw this one with gemini" or "try the best image model" and it can, just in time. +## Which image model was used, and how do I see the prompt you sent? + +**The step row names it, and opening the step shows the input.** + +While a picture is being drawn the row is the tool and its clock. When it +finishes the row reads: + +``` +generate_image circle.png · seedream-5 1.2 MB · 2.4s +``` + +The file is what you are reading for, so it leads; the image model behind the +middle dot is dim, because it qualifies the file rather than being the point of +it. It is the **short** spelling — the vendor prefix comes off, so +`bytedance/seedream-5` reads `seedream-5`. If the model is not known — the call +is still running, or the conversation was recorded by an older build — the row +says **nothing at all** there rather than a placeholder. + +Click the row, or select it with ↑/↓ and press **enter**, to open the step. It +shows what went in, as prose rather than as JSON: + +``` +"a small red circle, flat vector, centred on white" +1024x1024 +from art/sketch.png +drawn with seedream-5 +``` + +The prompt first, in quotes, exactly as it was sent — a very long one folds at +twelve lines and `… N more lines` lifts the rest. Then one line for each other +input you gave: the size or the aspect ratio, and the pictures it worked from. +An input you left out has no line, because leaving `size` out means the image +model's own default and aforge will not guess at what that is. The picture +itself, and the path under it, follow below. + +If the call asked for a model in its own words, the line keeps both halves — +`asked for best · drawn with seedream-5` — so you can see what was requested and +what actually ran. A running call that asked for one says `asked for seedream` +until it finishes. + +A call that **failed** has no file and no model beside it on the row — there was +no picture to attribute. The model it tried is named inside the failure the +expansion shows, e.g. `Image generation failed (bytedance-seed/seedream-5-0-pro): +…`. + +To change which model draws, just say so: "draw this one with gemini", "use the +best image model". That is the `model` argument on the tool, described in the +section above, and it changes nothing permanently. For a new default, use +`/settings` → Providers. + ## Why does a picture or video look generic, blurry, or like AI slop? Four causes, all fixable — none of them is "the model is bad at this". diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 3f969c5c07..1e6cb777b1 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -1379,6 +1379,12 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { "making-pictures-audio-and-video"}, {"how do I find the file for the image you generated", "making-pictures-audio-and-video"}, {"why is the picture you generated over --host not painted in my terminal", "making-pictures-audio-and-video"}, + // The step row names the image model behind the file, and opening the + // step shows what was sent to it. Both are asked about the PICTURE — the + // person is looking at a row in their transcript — so they land on the + // making page rather than on the screen or the model pages. + {"which image model drew this picture", "making-pictures-audio-and-video"}, + {"show me the prompt you sent to the image model", "making-pictures-audio-and-video"}, // Written from a real run: twelve clips rendered in parallel with // nothing shared, stitched with a video-only crossfade — the person // asked all three of these, in these words, and the answers were diff --git a/internal/session/tools_image_test.go b/internal/session/tools_image_test.go index fa816c0d08..7df54fa541 100644 --- a/internal/session/tools_image_test.go +++ b/internal/session/tools_image_test.go @@ -723,3 +723,57 @@ func TestGenerateImageRefusesAReferenceItCannotRead(t *testing.T) { t.Fatalf("a refused reference still cost %d generations", len(painter.seen)) } } + +// ── the model the surface reads back ──────────────────────────────────────── + +// THE RESULT LINE IS THE SURFACE'S ONE ROAD TO THE IMAGE MODEL, and the shape +// of the sentence is the contract. +// +// internal/tui3 draws "which model drew this" on the step row by reading the +// tail of this line (its imagepreview.go's [generatedPictureModel]) — there is +// no second field on the wire carrying it, because the model is CHOSEN inside +// this tool, after the call's own word has been resolved against the catalog, +// and a name resolved in one place and re-derived in another is a name that +// will one day disagree with itself. +// +// So this test asserts the parse rather than the prose: the model that rode the +// request is what stands after the marker, at the end of the line, whether it +// came from the slot default or from the call's own word. Reword the sentence +// and this fails here, where the fix is cheap — rather than in a terminal, +// where the row simply stops saying anything and nobody is told why. +func TestTheImageResultNamesTheModelWhereASurfaceCanReadIt(t *testing.T) { + // The same marker internal/tui3 looks for. It is written down twice on + // purpose: this copy is the test's own, so a change to either side of the + // contract has to be made deliberately on both. + const marker = ", generated on " + + painter := &scriptedMedia{base64: base64.StdEncoding.EncodeToString(pngOfSize(t, 8, 8))} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Media = painter + config.MediaModel = mediaModels(map[string]string{modalityImage: "paint/default"}) + config.MediaPick = pickTable(map[string]string{"image/crayon": "paint/crayon-2"}) + }) + + for index, testCase := range []struct{ args, want string }{ + {`{"prompt":"a lighthouse"}`, "paint/default"}, + {`{"prompt":"a lighthouse","model":"crayon"}`, "paint/crayon-2"}, + } { + result, isError := runTool(t, agent, "generate_image", testCase.args) + if isError { + t.Fatalf("%s failed: %s", testCase.args, result) + } + line, _, _ := strings.Cut(strings.TrimSpace(result), "\n") + at := strings.LastIndex(line, marker) + if at < 0 { + t.Fatalf("the result carries no %q marker for a surface to read: %q", marker, line) + } + if got := strings.TrimSpace(line[at+len(marker):]); got != testCase.want { + t.Fatalf("the line ends on %q, want the model %q", got, testCase.want) + } + // And it is the model the REQUEST carried, which is the whole point of + // reading it here rather than guessing it there. + if got := painter.request(index).Model; got != testCase.want { + t.Fatalf("the request rode %q while the line said %q", got, testCase.want) + } + } +} diff --git a/internal/tui3/imagecall.go b/internal/tui3/imagecall.go new file mode 100644 index 0000000000..cc2fc58723 --- /dev/null +++ b/internal/tui3/imagecall.go @@ -0,0 +1,177 @@ +package tui3 + +import ( + "encoding/json" + "strings" + + "github.com/Agent-Field/aforge-v2/internal/tui2/modelui" +) + +// WHAT WENT INTO A PICTURE, when somebody opens the step. +// +// The row above says which file came out and which image model drew it +// (toolstat.go's [app.pictureTarget]); this is the other half of the same +// question, and it is the half a person opens the step for — they want to read +// the prompt, because the prompt is the part they would change. It is drawn as +// PROSE and never as the JSON it arrived in: a person opening a row to read one +// sentence should not have to find it inside a payload, and every other +// expansion on this surface already shapes its tool's arguments for reading +// (toolview.go's [app.detailBody] — an edit's diff, a bash call's command). +// +// The two tiers are the row's own (toolview.go's parameter hierarchy). The +// prompt is the substance and takes the primary ink; everything that QUALIFIES +// it — the frame it was asked for, the pictures it worked from, the model that +// drew — recedes to dim, one fact per line. +// +// EVERYTHING HERE COMES OFF THE ROADS THE ROW ALREADY DRAWS FROM: the arguments +// session already sends for this call (Event.Args, bounded by its own +// argsLimit and marked where it was cut, so a prompt too long to carry arrives +// saying so and is never fetched a second way), and the model out of the tool's +// own result. Nothing is asked of the disk and nothing new is put on the wire. + +// promptWindow caps the prompt block, in lines, like every other expansion cap +// (toolstat.go's table). Twelve wrapped lines is a long and specific prompt +// read whole; past that the block folds and "… N more lines" lifts it, which is +// the same gesture the rest of this surface answers a long expansion with. +const promptWindow = 12 + +// imageCallRows is the prose head of an open `generate_image` step: the prompt, +// then one dim line per other input, then the model line. It returns the rows +// it kept and how many it dropped, exactly as [app.cap] does, because the +// prompt is the one part of it that can run long. +// +// It answers nothing at all for a call whose arguments never arrived — a begin +// with no payload, which is what a provider that does not stream tool calls +// sends — and then the expansion is the picture alone, exactly as it was. +func (a *app) imageCallRows(e *entry, width int) ([]string, int) { + // IT ANSWERS FOR ONE HAND AND SAYS SO ITSELF, the way [app.commandRows] + // answers only for a call carrying a command. The running expansion asks + // this block without knowing which tool it is drawing (toolview.go's + // [app.liveDetail]), and the other making verbs carry a `prompt` field too — + // a video's, a tune's — which this block's words are wrong about: "drawn + // with" is a sentence about a picture. So the gate is here, once, rather + // than in each caller. + if e == nil || e.tool != "generate_image" { + return nil, 0 + } + fields := argsOf(e.detail.Args) + rows, more := a.cappedPromptRows(e, argString(fields, "prompt"), width) + for _, line := range imageInputWords(fields) { + rows = append(rows, a.pal.dim(fit(line, width))) + } + if line := imageModelLine(fields, e.detail.Output); line != "" { + rows = append(rows, a.pal.dim(fit(line, width))) + } + return rows, more +} + +// cappedPromptRows is the prompt, quoted and wrapped to the block's width. +// +// It is QUOTED because it is somebody's own words sitting among facts about +// them, and the quotes are the plain ASCII pair rather than the typographic +// one: this block is drawn on every terminal this surface reaches, including +// the ones with no repertoire beyond ASCII, and a quote mark is not a mark from +// the vocabulary that could answer for those (docs/design/icons/DESIGN.md +// governs glyphs, and this is punctuation). +// +// A call with no prompt draws nothing rather than an empty pair of quotes. +func (a *app) cappedPromptRows(e *entry, prompt string, width int) ([]string, int) { + // The prompt is SOMEBODY ELSE'S BYTES and reaches this block with whatever + // the model put in it, so every line of it goes through [drawableLine] — + // which drops the newline with the other control bytes. The paragraph + // breaks the author wrote are kept by splitting first and rejoining after, + // because a prompt written in stanzas is a prompt read in stanzas. + lines := strings.Split(strings.ReplaceAll(prompt, "\r\n", "\n"), "\n") + for index, line := range lines { + lines[index] = drawableLine(line) + } + prompt = strings.TrimSpace(strings.Join(lines, "\n")) + if prompt == "" { + return nil, 0 + } + wrapped := wrap(`"`+prompt+`"`, width) + painted := make([]string, 0, len(wrapped)) + for _, line := range wrapped { + painted = append(painted, a.pal.ink(fit(line, width))) + } + if e.full || len(painted) <= promptWindow { + return painted, 0 + } + return painted[:promptWindow], len(painted) - promptWindow +} + +// imageInputWords is every OTHER input the call carried, one short line each, +// in the order a person asks about them: the shape of the frame, then the +// pictures it was told to work from. +// +// An input nobody gave is not a line. There is no "default" row and no "—": +// leaving `size` out means the image model's own default, and a surface that +// wrote the word "default" would be claiming to know what that is. +func imageInputWords(fields map[string]json.RawMessage) []string { + var out []string + // The two frame arguments are one fact — how big, or what shape — and the + // tool passes whichever arrived through untouched, so the line says the + // caller's own spelling rather than a normalized one. + if size := strings.TrimSpace(argString(fields, "size")); size != "" { + out = append(out, size) + } + if ratio := strings.TrimSpace(argString(fields, "aspect_ratio")); ratio != "" { + out = append(out, ratio) + } + if from := argPaths(fields, "reference_paths"); len(from) > 0 { + out = append(out, "from "+strings.Join(from, ", ")) + } + return out +} + +// imageModelLine is what the block says about which model drew. +// +// THREE SHAPES, AND WHICH ONE APPEARS IS A FACT RATHER THAN A CHOICE: +// +// - `drawn with paint-5` — the call left the model out, or asked for the name +// it got. This is the ordinary line. +// - `asked for best · drawn with paint-5` — the call asked in its own words +// and the catalog answered with a name. Both halves are worth keeping: the +// word is what somebody would repeat, the slug is what actually ran. +// - `asked for seedream` alone — the call is still running, so the request is +// known and the answer is not yet. +// +// A call that named no model and has not finished produces NOTHING, which is +// the emptiness law: the default is resolved inside the tool against the live +// catalog, and a surface that printed its own guess at it would be inventing a +// name three layers from anyone who could check it. +func imageModelLine(fields map[string]json.RawMessage, output string) string { + asked := strings.TrimSpace(argString(fields, "model")) + drew := modelui.ModelWord(generatedPictureModel(output)) + switch { + case drew == "" && asked == "": + return "" + case drew == "": + return "asked for " + asked + case asked == "" || strings.EqualFold(asked, drew): + return "drawn with " + drew + default: + return "asked for " + asked + " · drawn with " + drew + } +} + +// argPaths reads one array-of-strings argument as the drawable words a row can +// show. A field that is not an array of strings yields nothing rather than its +// raw JSON, because this block's whole promise is that it is prose. +func argPaths(fields map[string]json.RawMessage, key string) []string { + raw, ok := fields[key] + if !ok { + return nil + } + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(drawableLine(value)); value != "" { + out = append(out, value) + } + } + return out +} diff --git a/internal/tui3/imagemodel_test.go b/internal/tui3/imagemodel_test.go new file mode 100644 index 0000000000..2e488edc1e --- /dev/null +++ b/internal/tui3/imagemodel_test.go @@ -0,0 +1,150 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/Agent-Field/aforge-v2/internal/tui2/tokens" +) + +// WHICH IMAGE MODEL DREW, and what it was asked for. +// +// `generate_image` is the one hand where the model behind the call is a choice +// a person makes per call, so the row says which one ran and the expansion says +// what it was given. Both facts come off roads the row already draws from — the +// arguments session sends, and the tool's own result line — which is what these +// tests pin: the words, and the nothing that is drawn when nobody knows. + +// The finished row names the file and then the image model, and the model is a +// qualifier behind it rather than the substance. +func TestTheImageRowNamesTheModelThatDrew(t *testing.T) { + a := toolApp(t, tokens.ANSI256, call("generate_image", + `{"prompt":"a harbour at dawn","path":"harbour.png"}`, + "harbour.png — 64×32 png, 1.2KB, generated on vendor/paint-5")) + + line := toolLineOf(t, a) + if !strings.Contains(line, "harbour.png · paint-5") { + t.Fatalf("the row did not name the image model:\n%s", line) + } +} + +// And the vendor prefix comes off, because that is the spelling this surface +// already gives a model everywhere else ([modelui.ModelWord]). +func TestTheImageRowSpellsTheModelTheShortWay(t *testing.T) { + a := toolApp(t, tokens.ANSI256, call("generate_image", + `{"prompt":"a harbour","path":"harbour.png"}`, + "harbour.png — 64×32 png, 1.2KB, generated on vendor/paint-5")) + + if line := toolLineOf(t, a); strings.Contains(line, "vendor/paint-5") { + t.Fatalf("the row carried the provider prefix:\n%s", line) + } +} + +// A result that names no model adds NOTHING to the row — not a placeholder and +// not a dangling separator. This is the running call, and the transcript +// replayed from an engine that never wrote the marker. +func TestAnUnknownImageModelAddsNothingToTheRow(t *testing.T) { + a := toolApp(t, tokens.ANSI256, call("generate_image", + `{"prompt":"a harbour","path":"harbour.png"}`, "harbour.png — 64×32 png, 1.2KB")) + + line := toolLineOf(t, a) + if !strings.Contains(line, "harbour.png") { + t.Fatalf("the file went missing with the model:\n%s", line) + } + if strings.Contains(line, "harbour.png ·") { + t.Fatalf("a qualifier was drawn for a model nobody knows:\n%s", line) + } +} + +// Opening the step shows what went IN: the prompt in the person's own words, +// and the model that drew under it. +func TestOpeningAnImageStepShowsThePromptAndTheModel(t *testing.T) { + a, _ := pictureApp(t, call("generate_image", + `{"prompt":"a harbour at dawn","path":".aforge-v3/images/harbour.png"}`, + ".aforge-v3/images/harbour.png — 64×32 png, 1.2KB, generated on vendor/paint-5")) + + body := strings.Join(openFirst(t, a), "\n") + if !strings.Contains(body, `"a harbour at dawn"`) { + t.Fatalf("the prompt was not shown:\n%s", body) + } + if !strings.Contains(body, "drawn with paint-5") { + t.Fatalf("the model line was not shown:\n%s", body) + } + // The picture is still the answer under it all. + if !strings.Contains(body, halfBlock) { + t.Fatalf("the picture went away:\n%s", body) + } +} + +// A call that asked in its own words keeps BOTH halves: what somebody would +// say again, and what actually ran. +func TestAnImageStepThatAskedForAModelSaysWhatItGot(t *testing.T) { + a, _ := pictureApp(t, call("generate_image", + `{"prompt":"a harbour","path":".aforge-v3/images/harbour.png","model":"best"}`, + ".aforge-v3/images/harbour.png — 64×32 png, 1.2KB, generated on vendor/paint-5")) + + body := strings.Join(openFirst(t, a), "\n") + if !strings.Contains(body, "asked for best · drawn with paint-5") { + t.Fatalf("the request and the answer were not both kept:\n%s", body) + } +} + +// And an expansion with no model anywhere says nothing about one. +func TestAnImageStepWithNoModelAnywhereSaysNothingAboutOne(t *testing.T) { + a, _ := pictureApp(t, call("generate_image", + `{"prompt":"a harbour","path":".aforge-v3/images/harbour.png"}`, + ".aforge-v3/images/harbour.png — 64×32 png, 1.2KB")) + + body := strings.Join(openFirst(t, a), "\n") + if !strings.Contains(body, `"a harbour"`) { + t.Fatalf("the prompt was not shown:\n%s", body) + } + if strings.Contains(body, "drawn with") || strings.Contains(body, "asked for") { + t.Fatalf("a model was named where none is known:\n%s", body) + } +} + +// The other inputs are one dim line each, and an input nobody gave is not a +// line at all. +func TestTheImageStepShowsTheInputsItWasGiven(t *testing.T) { + a, _ := pictureApp(t, call("generate_image", + `{"prompt":"a harbour","path":".aforge-v3/images/harbour.png","size":"1024x1024",`+ + `"reference_paths":["art/sketch.png"]}`, + ".aforge-v3/images/harbour.png — 64×32 png, 1.2KB, generated on vendor/paint-5")) + + body := strings.Join(openFirst(t, a), "\n") + for _, want := range []string{"1024x1024", "from art/sketch.png"} { + if !strings.Contains(body, want) { + t.Fatalf("%q was not shown:\n%s", want, body) + } + } + // Nothing was given for the frame's shape, so there is no line for it. + if strings.Contains(body, "16:9") { + t.Fatalf("an input nobody gave was drawn:\n%s", body) + } +} + +// The three shapes of the model line, stated once, away from the frame. +func TestTheModelLineSaysOnlyWhatIsKnown(t *testing.T) { + for _, testCase := range []struct { + name string + args string + output string + want string + }{ + {"nothing known", `{"prompt":"x"}`, "", ""}, + {"only the answer", `{"prompt":"x"}`, + "a.png — 1KB, generated on vendor/paint-5", "drawn with paint-5"}, + {"only the request", `{"prompt":"x","model":"seedream"}`, "", "asked for seedream"}, + {"both, and they differ", `{"prompt":"x","model":"best"}`, + "a.png — 1KB, generated on vendor/paint-5", "asked for best · drawn with paint-5"}, + {"both, and they agree", `{"prompt":"x","model":"paint-5"}`, + "a.png — 1KB, generated on vendor/paint-5", "drawn with paint-5"}, + } { + t.Run(testCase.name, func(t *testing.T) { + if got := imageModelLine(argsOf(testCase.args), testCase.output); got != testCase.want { + t.Fatalf("line = %q, want %q", got, testCase.want) + } + }) + } +} diff --git a/internal/tui3/imagepreview.go b/internal/tui3/imagepreview.go index 36e85e00e5..9fb2896170 100644 --- a/internal/tui3/imagepreview.go +++ b/internal/tui3/imagepreview.go @@ -727,3 +727,30 @@ func generatedPicturePath(output string) string { } return "" } + +// generatedModelMarker is what `generate_image`'s result line puts in front of +// the model that drew the picture (internal/session's [describeGeneratedImage] +// writes it). It is spelled here rather than derived because this file already +// reads that line's other half the same way ([generatedPicturePath]), and one +// parser for one sentence is easier to keep true than two halves in two places. +const generatedModelMarker = ", generated on " + +// generatedPictureModel is the image model a finished `generate_image` call +// actually drew with, read out of the tail of its own result line: +// +// .aforge-v3/images/harbour.png — 1024×1024 png, 1.4MB, generated on vendor/paint-5 +// +// THE RESULT IS THE ONE AUTHORITY ON THIS. The model that drew is chosen inside +// the tool, after the call's own `model` word has been resolved against the +// catalog — so a word the caller wrote ("best", "seedream") is a REQUEST and +// this is the answer. A call that has not finished, or whose result does not +// carry the marker, yields nothing, and the row draws nothing: the emptiness +// law, applied to a name nobody here is in a position to invent. +func generatedPictureModel(output string) string { + line := firstLine(strings.TrimSpace(resultText(output))) + at := strings.LastIndex(line, generatedModelMarker) + if at < 0 { + return "" + } + return strings.TrimSpace(line[at+len(generatedModelMarker):]) +} diff --git a/internal/tui3/imagepreview_test.go b/internal/tui3/imagepreview_test.go index 9788a7d504..b33971dfc8 100644 --- a/internal/tui3/imagepreview_test.go +++ b/internal/tui3/imagepreview_test.go @@ -326,9 +326,11 @@ func wideTestPicture() image.Image { return img } -// Opening a generated picture shows the picture and the file, and nothing else: -// the result's one line said where the file was and how big it was, which is -// exactly what the line under the picture says. +// Opening a generated picture shows the picture and the file, and never the +// result's own line over the top of them: that line said where the file was and +// how big it was, which is exactly what the line under the picture says. (What +// went IN — the prompt, the model that drew — leads the block above it, and +// imagemodel_test.go is where those words are pinned.) func TestOpeningAGeneratedPictureDrawsThePicture(t *testing.T) { a, path := pictureApp(t, call("generate_image", `{"prompt":"a harbour at dawn"}`, diff --git a/internal/tui3/toolstat.go b/internal/tui3/toolstat.go index 6952efb60e..4ad90e7965 100644 --- a/internal/tui3/toolstat.go +++ b/internal/tui3/toolstat.go @@ -2,11 +2,13 @@ package tui3 import ( "encoding/json" + "path/filepath" "regexp" "strconv" "strings" "github.com/Agent-Field/aforge-v2/internal/search" + "github.com/Agent-Field/aforge-v2/internal/tui2/modelui" ) // PER-TOOL DERIVATION (docs/CHAT-V3.md D11). @@ -147,6 +149,81 @@ func toolTarget(tool, args, hint string) string { return gloss } +// toolTargetOf is [toolTarget] for a caller holding the whole entry — the +// call's RESULT as well as its arguments. +// +// It exists for the two picture hands and for nothing else, because they are +// the two whose target is not entirely in the arguments: `generate_image` +// CHOOSES the file's name when the caller did not, so the only record of where +// the picture went is the result, and the model that drew it is only ever +// there. Every other tool falls straight through to [toolTarget] and is drawn +// from its arguments exactly as it always was. +// +// One door, so the conversation's rows and a task room's rows — which are the +// same composer over two lists (toolview.go's [app.clusterRows]) — cannot come +// to two different answers about what a picture call is about. +func (a *app) toolTargetOf(e *entry) string { + if e == nil { + return "" + } + if picturesAFile(e.tool) { + if target := a.pictureTarget(e); target != "" { + return target + } + } + return toolTarget(e.tool, e.detail.Args, e.text) +} + +// pictureTarget is the sentence a picture row says: the file the call is about, +// and — for a generation that has finished — the image model that drew it. +// +// THE MODEL IS A QUALIFIER AND NOT THE SUBSTANCE (toolview.go's parameter +// hierarchy). A person reads the row for the picture they asked for; which +// machine drew it is the fact BEHIND that, and it recedes accordingly. It is +// joined with the middle dot this surface joins every trailing fact with, which +// is what lets [toolFit] cut the path and keep the model, and what lets +// [app.paintTarget] dim it without either of them knowing this tool's name. +// +// An unknown model adds nothing at all — not a placeholder, not a dash. The +// running call, the engine that predates the marker and the terminal replaying +// an old transcript all land here, and all three draw the file alone. +func (a *app) pictureTarget(e *entry) string { + file := a.pictureFileWord(e) + if file == "" { + return "" + } + word := modelui.ModelWord(generatedPictureModel(e.detail.Output)) + if word == "" { + return file + } + return file + " · " + word +} + +// pictureFileWord is the picture's path AS A ROW SHOULD SAY IT: the caller's own +// spelling when the arguments carry one, and otherwise the path out of the +// result, made relative to this conversation's folder when it lies inside it. +// +// The result's path is whole and absolute on purpose (internal/session's +// [picturePathInResult]), because it is the only record a terminal that cannot +// draw the picture has. A ROW IS NOT THAT RECORD — the expansion under it still +// prints the path whole ([app.pictureWords]) — so the row spends its cells on +// the part that identifies the file and lets the leading directories go. +func (a *app) pictureFileWord(e *entry) string { + if named := strings.TrimSpace(argString(argsOf(e.detail.Args), "path")); named != "" { + return filepath.ToSlash(named) + } + made := strings.TrimSpace(generatedPicturePath(e.detail.Output)) + if made == "" { + return "" + } + if a.workspace != "" && filepath.IsAbs(made) { + if inside, err := filepath.Rel(a.workspace, made); err == nil && !strings.HasPrefix(inside, "..") { + return filepath.ToSlash(inside) + } + } + return filepath.ToSlash(made) +} + // toolStat is the dim figure trailing a tool line: the one number that says // how big the call was. It returns the plain text (for width) and the painted // text (for the screen), because an edit's stat is two colours and a width diff --git a/internal/tui3/toolview.go b/internal/tui3/toolview.go index 6da8e7e9cc..4d32f7a42b 100644 --- a/internal/tui3/toolview.go +++ b/internal/tui3/toolview.go @@ -436,7 +436,7 @@ func (a *app) toolLineLayoutWithKeep(e *entry, i int, last bool, width int, show // take the phone's shape two cells early. width -= workIndentCols(width) name, fallback := toolWords(e.tool, e.text) - target := toolTarget(e.tool, e.detail.Args, e.text) + target := a.toolTargetOf(e) if target == "" { target = fallback } @@ -697,7 +697,13 @@ const elideFloor = 12 // ./internal/…/tui3` would hide the verb and keep the argument. func toolFit(tool, target string, width int) (string, int) { switch tool { - case "read", "edit", "write", "ls", "web_fetch": + // The two picture hands join this family because their target is a path + // too, and because the fact behind it — the image model that drew — is the + // one thing on the row a person cannot get back by opening the file + // (toolstat.go's [app.pictureTarget]). Cutting the path in the middle and + // keeping the qualifier whole is exactly what this branch already does for + // a read's line range. + case "read", "edit", "write", "ls", "web_fetch", "generate_image", "view_image": // The qualifier keeps its place: it is the line range the path was read // with, and a cut that took it would leave a path claiming it was read // whole (the parameter hierarchy, below, is what paints the two). @@ -798,7 +804,7 @@ const phoneGutterWidth = 2 func (a *app) toolLinePhone(e *entry, i int, last bool, width int) string { name, fallback := toolWords(e.tool, e.text) - target := toolTarget(e.tool, e.detail.Args, e.text) + target := a.toolTargetOf(e) if target == "" { target = fallback } @@ -906,9 +912,11 @@ func phoneTarget(tool, target string) string { pattern, _, _ := strings.Cut(target, " ") return pattern - case "read", "edit", "write", "ls": + case "read", "edit", "write", "ls", "generate_image", "view_image": // The path elided to its tail, and the line range after it dropped: at // this width "120-240" is four files' worth of the name it qualifies. + // A picture call sheds its image model here for the same reason and by + // the same rule — a qualifier is the first thing this tier gives up. path, _, _ := strings.Cut(target, " ") return pathTail(path) } @@ -1483,13 +1491,24 @@ func (a *app) paintTarget(e *entry, target string) string { } return a.pal.shell(target) - case "read", "edit", "write": + case "read", "edit", "write", "generate_image", "view_image": // AND THE TARGET IS THE DOOR ITSELF (pathlink.go). This is the one path // on the row that aforge resolved rather than found — it came out of the // call's own arguments — so it is exactly the kind of path that may be // linked, and the shown text may be an ellipsis or a bare basename // without the click losing the file. name := argString(argsOf(e.detail.Args), "path") + // A PICTURE CALL MAY HAVE NAMED NO FILE AT ALL and still have written + // one: `generate_image` chooses the name when the caller did not, and + // [app.picturePath] is the one place that knows the whole rule for + // finding it — arguments first, the result second, absolute beating + // relative. Asking it here is what makes the row of a picture nobody + // named open the file a click. + if picturesAFile(e.tool) { + if resolved, found := a.picturePath(e); found { + name = resolved + } + } path, rest, found := strings.Cut(target, " ") if !found { return a.pathLink(name, a.pal.ink(target)) @@ -1713,20 +1732,34 @@ func (a *app) detailBody(e *entry, width int) ([]string, int) { body, more := a.cap(e, said, bashWindow) return append(head, body...), more case "generate_image": - // THE PICTURE IS THE WHOLE ANSWER, and the line under it already says - // everything this call's result says — where the file is and how big it + // WHAT WAS ASKED FOR, AND THEN WHAT CAME BACK. The prompt and the rest + // of the inputs lead, drawn as prose by imagecall.go, because they are + // what a person opens this particular row to read — the picture itself + // they can already see in the transcript, and the prompt is the part + // they would change. Under them the picture, whose own line already says + // everything this call's RESULT says: where the file is and how big it // is. Printing the result underneath as well would be the expansion // answering one question twice (imagepreview.go). + asked, more := a.imageCallRows(e, width) if picture, drawn := a.pictureRows(e, width); drawn { - return picture, 0 + return append(asked, picture...), more } // AND WHERE NO PICTURE CAN BE DRAWN, THE PATH STILL GOES DOWN WHOLE. // This is the terminal that reached sixteen colours, or the file this // program cannot decode, and it is exactly the case where a person needs // to leave and open the file themselves — so the one thing the fallback // must not do is truncate the only string that would let them. - if rows, more, known := a.pictureWords(e, width); known { - return rows, more + // + // The two drops are added rather than chosen between: a prompt folded at + // its window and a result folded at its own are both hidden lines, the + // foot under them offers to lift the block, and `e.full` lifts both. + if rows, dropped, known := a.pictureWords(e, width); known { + return append(asked, rows...), more + dropped + } + // A call with no file to point at yet — one still running, one that + // failed before it wrote anything — is still worth what it asked for. + if len(asked) > 0 { + return asked, more } case "view_image": // A LOOK HAS TWO HALVES and they are both worth the rows: the picture, @@ -1782,6 +1815,14 @@ func (a *app) liveDetail(e *entry, width int) ([]string, int, bool) { if command := a.commandRows(e, width); len(command) > 0 { return append(command, a.livePhrase(e)), 0, true } + // A PICTURE'S PROMPT IS READABLE BEFORE THE PICTURE IS, and by the same + // argument: the prompt is the whole of what a person opened a running + // generation for, and the file it will write does not exist yet. The block + // answers only for the hand it is about (imagecall.go), so this stays one + // question about the arguments rather than a table of tool names. + if asked, _ := a.imageCallRows(e, width); len(asked) > 0 { + return append(asked, a.livePhrase(e)), 0, true + } return []string{a.livePhrase(e)}, 0, true }