From f2b4c7f0196e1c46f843b19857907ed011674685 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:57:13 -0400 Subject: [PATCH 1/3] tui3: a picture's step row names the image model, and opening it shows the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generate_image` is the one hand whose model a person chooses per call, and until now the step row said neither which model drew nor what it was asked for. The row was the bare tool name — `generate_image` and a clock — because the tool is in no gloss table and its target was never read out of anything. The row now reads `generate_image circle.png · seedream-5`. The file is the substance and takes the ink; the image model is a qualifier behind the middle dot and recedes to dim, which is the parameter hierarchy toolview.go already states for a read's line range and a bash call's `cd` prefix. It rides the two roads the row already draws from and no third: the file comes from the call's own `path` argument, or — because the tool names the file itself when nobody did — out of the result, through the same [app.picturePath] rule the picture preview has always used; the model comes out of the tail of that same result line, which is where the tool writes the model the request actually carried. Nothing new goes on the wire, and the surface never guesses at a default it is not in a position to know: an unknown model draws nothing at all. Opening the step shows what went IN, as prose rather than as the JSON it arrived in. The prompt leads, quoted and wrapped and capped at twelve lines like every other expansion; under it one dim line per other input the call gave, and then the model line — `drawn with seedream-5`, or `asked for best · drawn with seedream-5` when the call asked in its own words and the catalog answered with a name. A running generation shows the same block above its clock, because a prompt is readable long before the picture is. The picture and its whole path still close the block, untouched. The word is [modelui.ModelWord], which is the spelling this surface already uses for a model everywhere else, so no fourth one was invented. The picture hands join toolFit's middle-cut family and phoneTarget's basename family, so a narrow row cuts the path and keeps the model and a phone row gives the qualifier up first — both by the rules those branches already applied, not by a new one. A session test pins the contract the two packages share: the model that rode the request is what stands after `, generated on ` at the end of the result's first line, for the default and for a picked word alike. Reword that sentence and it fails there, where the fix is cheap, rather than in a terminal where the row would simply go quiet. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../chat/making-pictures-audio-and-video.md | 45 +++++ internal/manual/chat_test.go | 6 + internal/session/tools_image_test.go | 54 ++++++ internal/tui3/imagecall.go | 177 ++++++++++++++++++ internal/tui3/imagemodel_test.go | 150 +++++++++++++++ internal/tui3/imagepreview.go | 27 +++ internal/tui3/imagepreview_test.go | 8 +- internal/tui3/toolstat.go | 77 ++++++++ internal/tui3/toolview.go | 61 +++++- 9 files changed, 592 insertions(+), 13 deletions(-) create mode 100644 internal/tui3/imagecall.go create mode 100644 internal/tui3/imagemodel_test.go diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index fc983e7efa..f62af920df 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -133,6 +133,51 @@ 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. + +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 } From 06ec16e1e0eff00405da3c6bc1d7e08c95c9f3bc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 22:58:11 -0400 Subject: [PATCH 2/3] docs: the change entry for #1026 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- .../unreleased/1026-image-step-names-model.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/changes/unreleased/1026-image-step-names-model.md 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. From 54496f5928919c35a35a2dee6ac5a7dca545e2f6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Sun, 13 Sep 2026 23:18:57 -0400 Subject: [PATCH 3/3] manual: a failed picture names its model inside the failure, not beside the file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Met on the real-model drive: seedream refused a 256x256 request, so the call had no file and no attribution to put on the row — and the model it tried was in the failure line the expansion shows. The page says so rather than leaving a person to wonder why the qualifier is missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0182eyxXP8Xe7ADNKpYDe4wv --- internal/manual/chat/making-pictures-audio-and-video.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index f62af920df..db891011f4 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -173,6 +173,11 @@ If the call asked for a model in its own words, the line keeps both halves — 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